diff --git a/ui/package-lock.json b/ui/package-lock.json index a294037e598..91d166d74fe 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1073,9 +1073,9 @@ "dev": true }, "@types/react": { - "version": "16.8.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.2.tgz", - "integrity": "sha512-6mcKsqlqkN9xADrwiUz2gm9Wg4iGnlVGciwBRYFQSMWG6MQjhOZ/AVnxn+6v8nslFgfYTV8fNdE6XwKu6va5PA==", + "version": "16.8.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.0.tgz", + "integrity": "sha512-phBajeyF9ZIYGWUHJV1+X5GHOtdUO0+qHamY4PXFPf6ntoDfW+VqOG8oyruD2K4rqApfOB1QKuQiNYUX8ejEpQ==", "dev": true, "requires": { "@types/prop-types": "*", @@ -10473,14 +10473,14 @@ } }, "react": { - "version": "16.8.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.8.0.tgz", - "integrity": "sha512-g+nikW2D48kqgWSPwNo0NH9tIGG3DsQFlrtrQ1kj6W77z5ahyIHG0w8kPpz4Sdj6gyLnz0lEd/xsjOoGge2MYQ==", + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/react/-/react-16.8.1.tgz", + "integrity": "sha512-wLw5CFGPdo7p/AgteFz7GblI2JPOos0+biSoxf1FPsGxWQZdN/pj6oToJs1crn61DL3Ln7mN86uZ4j74p31ELQ==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.0" + "scheduler": "^0.13.1" } }, "react-codemirror2": { @@ -10527,14 +10527,14 @@ } }, "react-dom": { - "version": "16.8.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.0.tgz", - "integrity": "sha512-dBzoAGYZpW9Yggp+CzBPC7q1HmWSeRc93DWrwbskmG1eHJWznZB/p0l/Sm+69leIGUS91AXPB/qB3WcPnKx8Sw==", + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.1.tgz", + "integrity": "sha512-N74IZUrPt6UiDjXaO7UbDDFXeUXnVhZzeRLy/6iqqN1ipfjrhR60Bp5NuBK+rv3GMdqdIuwIl22u1SYwf330bg==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.0" + "scheduler": "^0.13.1" } }, "react-draggable": { @@ -11635,9 +11635,9 @@ } }, "scheduler": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.0.tgz", - "integrity": "sha512-w7aJnV30jc7OsiZQNPVmBc+HooZuvQZIZIShKutC3tnMFMkcwVN9CZRRSSNw03OnSCKmEkK8usmwcw6dqBaLzw==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.1.tgz", + "integrity": "sha512-VJKOkiKIN2/6NOoexuypwSrybx13MY7NSy9RNt8wPvZDMRT1CW6qlpF5jXRToXNHz3uWzbm2elNpZfXfGPqP9A==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/ui/package.json b/ui/package.json index 7b357642b7e..fe5bc93602d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -126,6 +126,7 @@ "classnames": "^2.2.3", "codemirror": "^5.36.0", "d3-color": "^1.2.0", + "d3-format": "^1.3.2", "d3-scale": "^2.1.0", "dygraphs": "2.1.0", "encoding-down": "^5.0.4", diff --git a/ui/src/minard/components/Axes.tsx b/ui/src/minard/components/Axes.tsx new file mode 100644 index 00000000000..7a8ac47783f --- /dev/null +++ b/ui/src/minard/components/Axes.tsx @@ -0,0 +1,97 @@ +import React, {useRef, useLayoutEffect, SFC} from 'react' + +import {PlotEnv, TICK_PADDING_RIGHT, TICK_PADDING_TOP} from 'src/minard' +import {clearCanvas} from 'src/minard/utils/clearCanvas' + +interface Props { + env: PlotEnv + axesStroke?: string + tickFont?: string + tickFill?: string +} + +export const drawAxes = ( + canvas: HTMLCanvasElement, + env: PlotEnv, + axesStroke: string, + tickFont: string, + tickFill: string +) => { + const { + width, + height, + margins, + xTicks, + yTicks, + defaults: { + scales: {x: xScale, y: yScale}, + }, + } = env + + clearCanvas(canvas, width, height) + + const context = canvas.getContext('2d') + const xAxisY = height - margins.bottom + + // Draw x axis line + context.strokeStyle = axesStroke + context.beginPath() + context.moveTo(margins.left, xAxisY) + context.lineTo(width - margins.right, xAxisY) + context.stroke() + + // Draw y axis line + context.beginPath() + context.moveTo(margins.left, xAxisY) + context.lineTo(margins.left, margins.top) + context.stroke() + + context.font = tickFont + context.fillStyle = tickFill + context.textAlign = 'center' + context.textBaseline = 'top' + + // Draw and label each tick on the x axis + for (const xTick of xTicks) { + const x = xScale(xTick) + margins.left + + context.beginPath() + context.moveTo(x, xAxisY) + context.lineTo(x, margins.top) + context.stroke() + + context.fillText(xTick, x, xAxisY + TICK_PADDING_TOP) + } + + context.textAlign = 'end' + context.textBaseline = 'middle' + + // Draw and label each tick on the y axis + for (const yTick of yTicks) { + const y = yScale(yTick) + margins.top + + context.beginPath() + context.moveTo(margins.left, y) + context.lineTo(width - margins.right, y) + context.stroke() + + context.fillText(yTick, margins.left - TICK_PADDING_RIGHT, y) + } +} + +export const Axes: SFC = props => { + const {children, env, tickFill, tickFont, axesStroke} = props + const canvas = useRef(null) + + useLayoutEffect( + () => drawAxes(canvas.current, env, axesStroke, tickFont, tickFill), + [canvas.current, env, axesStroke, tickFont, tickFill] + ) + + return ( + <> + {children} + + + ) +} diff --git a/ui/src/minard/components/Histogram.tsx b/ui/src/minard/components/Histogram.tsx new file mode 100644 index 00000000000..b468004504d --- /dev/null +++ b/ui/src/minard/components/Histogram.tsx @@ -0,0 +1,147 @@ +import React, {useState, useEffect, SFC} from 'react' +import uuid from 'uuid' + +import {PlotEnv} from 'src/minard' +import * as stats from 'src/minard/utils/stats' +import {assert} from 'src/minard/utils/assert' +import {registerLayer, unregisterLayer} from 'src/minard/utils/plotEnvActions' +import HistogramBars from 'src/minard/components/HistogramBars' +import HistogramTooltip from 'src/minard/components/HistogramTooltip' +import {findHoveredRowIndices} from 'src/minard/utils/findHoveredRowIndices' + +export enum Position { + Stacked = 'stacked', + Overlaid = 'overlaid', +} + +export interface Props { + env: PlotEnv + x?: string + fill?: string + position?: Position + bins?: number + colors?: string[] + tooltip?: (props: TooltipProps) => JSX.Element +} + +export interface TooltipProps { + x: string + fill: string + xMin: number + xMax: number + counts: Array<{fill: string | number | boolean; count: number; color: string}> +} + +export const Histogram: SFC = props => { + const [layerKey] = useState(() => uuid.v4()) + + const {bins, position} = props + const {layers, defaults, dispatch} = props.env + const layer = layers[layerKey] + const table = defaults.table + const x = props.x || defaults.aesthetics.x + const fill = props.fill || defaults.aesthetics.fill + const colors = props.colors + + useEffect( + () => { + const xCol = table.columns[x] + const xColType = table.columnTypes[x] + const fillCol = table.columns[fill] + const fillColType = table.columnTypes[fill] + + assert('expected an `x` aesthetic', !!x) + assert(`table does not contain column "${x}"`, !!xCol) + + const [statTable, mappings] = stats.bin( + xCol, + xColType, + fillCol, + fillColType, + bins, + position + ) + + dispatch(registerLayer(layerKey, statTable, mappings, colors)) + + return () => dispatch(unregisterLayer(layerKey)) + }, + [table, x, fill, position, bins, colors] + ) + + if (!layer) { + return null + } + + const { + innerWidth, + innerHeight, + defaults: { + scales: {x: xScale, y: yScale, fill: layerFillScale}, + }, + } = props.env + + const { + aesthetics, + table: {columns}, + scales: {fill: defaultFillScale}, + } = layer + + const fillScale = layerFillScale || defaultFillScale + const xMinCol = columns[aesthetics.xMin] + const xMaxCol = columns[aesthetics.xMax] + const yMinCol = columns[aesthetics.yMin] + const yMaxCol = columns[aesthetics.yMax] + const fillCol = columns[aesthetics.fill] + + const {hoverX, hoverY} = props.env + + let hoveredRowIndices = null + + if (hoverX && hoverY) { + hoveredRowIndices = findHoveredRowIndices( + xMinCol, + xMaxCol, + yMaxCol, + xScale.invert(hoverX), + yScale.invert(hoverY) + ) + } + + return ( + <> + + {hoveredRowIndices && ( + + )} + + ) +} diff --git a/ui/src/minard/components/HistogramBars.tsx b/ui/src/minard/components/HistogramBars.tsx new file mode 100644 index 00000000000..993d4af84a2 --- /dev/null +++ b/ui/src/minard/components/HistogramBars.tsx @@ -0,0 +1,84 @@ +import React, {useRef, useLayoutEffect, SFC} from 'react' + +import {Scale, HistogramPosition} from 'src/minard' +import {clearCanvas} from 'src/minard/utils/clearCanvas' + +const BAR_TRANSPARENCY = 0.5 +const BAR_TRANSPARENCY_HOVER = 0.7 +const BAR_PADDING = 1.5 + +interface Props { + width: number + height: number + xMinCol: number[] + xMaxCol: number[] + yMinCol: number[] + yMaxCol: number[] + fillCol: string[] | boolean[] | number[] + xScale: Scale + yScale: Scale + fillScale: Scale + position: HistogramPosition + hoveredRowIndices: number[] | null +} + +const drawBars = ( + canvas: HTMLCanvasElement, + { + width, + height, + xMinCol, + xMaxCol, + yMinCol, + yMaxCol, + fillCol, + xScale, + yScale, + fillScale, + hoveredRowIndices, + }: Props +): void => { + clearCanvas(canvas, width, height) + + const context = canvas.getContext('2d') + + for (let i = 0; i < yMaxCol.length; i++) { + if (yMinCol[i] === yMaxCol[i]) { + continue + } + + const x = xScale(xMinCol[i]) + const y = yScale(yMaxCol[i]) + const width = xScale(xMaxCol[i]) - x - BAR_PADDING + const height = yScale(yMinCol[i]) - y - BAR_PADDING + const fill = fillScale(fillCol[i]) + const alpha = + hoveredRowIndices && hoveredRowIndices.includes(i) + ? BAR_TRANSPARENCY_HOVER + : BAR_TRANSPARENCY + + // See https://stackoverflow.com/a/45125187 + context.beginPath() + context.rect(x, y, width, height) + context.save() + context.clip() + context.lineWidth = 2 + context.globalAlpha = alpha + context.fillStyle = fill + context.fill() + context.globalAlpha = 1 + context.strokeStyle = fill + context.stroke() + context.restore() + } +} + +const HistogramBars: SFC = props => { + const canvas = useRef(null) + + useLayoutEffect(() => drawBars(canvas.current, props)) + + return +} + +export default React.memo(HistogramBars) diff --git a/ui/src/minard/components/HistogramTooltip.tsx b/ui/src/minard/components/HistogramTooltip.tsx new file mode 100644 index 00000000000..00119ac8078 --- /dev/null +++ b/ui/src/minard/components/HistogramTooltip.tsx @@ -0,0 +1,91 @@ +import React, {useRef, SFC} from 'react' + +import {Scale, HistogramTooltipProps} from 'src/minard' +import {useLayoutStyle} from 'src/minard/utils/useLayoutStyle' + +const MARGIN_X = 15 +const MARGIN_Y = 10 + +interface Props { + hoverX: number + hoverY: number + x: string + fill: string + tooltip?: (props: HistogramTooltipProps) => JSX.Element + width: number + height: number + xMinCol: number[] + xMaxCol: number[] + yMinCol: number[] + yMaxCol: number[] + fillCol: string[] | boolean[] + fillScale: Scale + hoveredRowIndices: number[] | null +} + +const HistogramTooltip: SFC = ({ + hoverX, + hoverY, + x, + fill, + tooltip, + width, + height, + xMinCol, + xMaxCol, + yMinCol, + yMaxCol, + fillCol, + fillScale, + hoveredRowIndices, +}: Props) => { + const tooltipEl = useRef(null) + + useLayoutStyle(tooltipEl, ({offsetWidth, offsetHeight}) => { + let dx = MARGIN_X + let dy = MARGIN_Y + + if (hoverX + MARGIN_X + offsetWidth > width) { + // If the tooltip overflows off the right edge of the visualization, + // position it on the left side of the mouse instead + dx = 0 - MARGIN_X - offsetWidth + } + + if (hoverY + MARGIN_Y + offsetHeight > height) { + // If the tooltip overflows off the bottom edge of the visualization, + // position it on the top side of the mouse instead + dy = 0 - MARGIN_Y - offsetHeight + } + + return { + position: 'absolute', + left: `${hoverX + dx}px`, + top: `${hoverY + dy}px`, + } + }) + + if (!hoveredRowIndices) { + return null + } + + const tooltipProps: HistogramTooltipProps = { + x, + fill, + xMin: xMinCol[hoveredRowIndices[0]], + xMax: xMaxCol[hoveredRowIndices[0]], + counts: hoveredRowIndices.map(i => ({ + fill: fillCol ? fillCol[i] : null, + count: yMaxCol[i] - yMinCol[i], + color: fillScale(fillCol[i]), + })), + } + + return ( +
+ {/* TODO: Provide a default tooltip implementation */} + {tooltip ? tooltip(tooltipProps) : null} +
+ ) +} + +export default HistogramTooltip diff --git a/ui/src/minard/components/Plot.tsx b/ui/src/minard/components/Plot.tsx new file mode 100644 index 00000000000..bf48083f350 --- /dev/null +++ b/ui/src/minard/components/Plot.tsx @@ -0,0 +1,117 @@ +import React, {useReducer, useEffect, useRef, SFC, CSSProperties} from 'react' + +import {Table, PlotEnv, CATEGORY_10} from 'src/minard' +import {Axes} from 'src/minard/components/Axes' +import {plotEnvReducer, INITIAL_PLOT_ENV} from 'src/minard/utils/plotEnvReducer' +import {useMousePos} from 'src/minard/utils/useMousePos' +import { + setDimensions, + setTable, + setColors, +} from 'src/minard/utils/plotEnvActions' + +export interface Props { + // Required props + // -------------- + // + table: Table + width: number + height: number + children: (env: PlotEnv) => JSX.Element + + // Aesthetic mappings + // ------------------ + // + x?: string + fill?: string + // y?: string + // start?: string + // stop?: string + // lower?: string + // upper?: string + // stroke?: string + // strokeWidth?: string + // shape?: ShapeKind + // radius?: number + // alpha?: number + + // Misc options + // ------------ + // + axesStroke?: string + tickFont?: string + tickFill?: string + colors?: string[] + // xBrushable?: boolean + // yBrushable?: boolean + // xAxisTitle?: string + // yAxisTitle?: string + // xAxisPrefix?: string + // yAxisPrefix?: string + // xAxisSuffix?: string + // yAxisSuffix?: string + // xTicksStroke?: string + // yTicksStroke?: string +} + +export const Plot: SFC = ({ + width, + height, + table, + x, + fill, + children, + colors = CATEGORY_10, + axesStroke = '#31313d', + tickFont = 'bold 10px Roboto', + tickFill = '#8e91a1', +}) => { + const [env, dispatch] = useReducer(plotEnvReducer, { + ...INITIAL_PLOT_ENV, + width, + height, + defaults: {table, colors, aesthetics: {x, fill}, scales: {}}, + }) + + // TODO: When should these actions be batched? + useEffect(() => dispatch(setTable(table)), [table]) + useEffect(() => dispatch(setDimensions(width, height)), [width, height]) + useEffect(() => dispatch(setColors(colors)), [colors]) + + const plotStyle: CSSProperties = { + position: 'relative', + width: `${width}px`, + height: `${height}px`, + } + + const layersStyle: CSSProperties = { + position: 'absolute', + top: `${env.margins.top}px`, + right: `${env.margins.right}px`, + bottom: `${env.margins.bottom}px`, + left: `${env.margins.left}px`, + } + + const mouseRegion = useRef(null) + const [hoverX, hoverY] = useMousePos(mouseRegion) + + return ( +
+ +
+ {children({...env, hoverX, hoverY, dispatch})} +
+
+ +
+ ) +} diff --git a/ui/src/minard/index.ts b/ui/src/minard/index.ts new file mode 100644 index 00000000000..f0a0516dd7c --- /dev/null +++ b/ui/src/minard/index.ts @@ -0,0 +1,169 @@ +import {PlotAction} from 'src/minard/utils/plotEnvActions' + +export const PLOT_PADDING = 20 + +export const TICK_PADDING_RIGHT = 8 +export const TICK_PADDING_TOP = 5 + +// TODO: Measure text metrics instead +export const TICK_CHAR_WIDTH = 7 +export const TICK_CHAR_HEIGHT = 10 + +export const CATEGORY_10 = [ + '#1f77b4', + '#ff7f0e', + '#2ca02c', + '#d62728', + '#9467bd', + '#8c564b', + '#e377c2', + '#7f7f7f', + '#bcbd22', + '#17becf', +] + +export {Plot} from 'src/minard/components/Plot' + +export { + Histogram, + Position as HistogramPosition, + TooltipProps as HistogramTooltipProps, +} from 'src/minard/components/Histogram' + +export interface Scale { + (x: D): R + invert?: (y: R) => D +} + +export interface AestheticDataMappings { + [aestheticName: string]: string +} + +export interface AestheticScaleMappings { + [aestheticName: string]: Scale +} + +export interface Layer { + table?: Table + aesthetics: AestheticDataMappings + scales: AestheticScaleMappings + colors?: string[] +} + +export interface Margins { + top: number + right: number + bottom: number + left: number +} + +export interface PlotEnv { + width: number + height: number + innerWidth: number + innerHeight: number + defaults: Layer + layers: {[layerKey: string]: Layer} + xDomain: number[] + yDomain: number[] + xTicks: string[] + yTicks: string[] + margins: Margins + hoverX: number + hoverY: number + dispatch: (action: PlotAction) => void +} + +export enum ColumnType { + Numeric = 'numeric', + Categorical = 'categorical', + Temporal = 'temporal', + Boolean = 'bool', +} + +export interface Table { + columns: {[columnName: string]: any[]} + columnTypes: {[columnName: string]: ColumnType} +} + +// export enum InterpolationKind { +// Linear = 'linear', +// MonotoneX = 'monotoneX', +// MonotoneY = 'monotoneY', +// Cubic = 'cubic', +// Step = 'step', +// StepBefore = 'stepBefore', +// StepAfter = 'stepAfter', +// } + +// export interface LineProps { +// x?: string +// y?: string +// stroke?: string +// strokeWidth?: string +// interpolate?: InterpolationKind +// } + +// export enum AreaPositionKind { +// Stack = 'stack', +// Overlay = 'overlay', +// } + +// export interface AreaProps { +// x?: string +// y?: string +// position?: AreaPositionKind +// } + +// export enum ShapeKind { +// Point = 'point', +// // Spade, Heart, Club, Triangle, Hexagon, etc. +// } + +// export interface PointProps { +// x?: string +// y?: string +// fill?: string +// shape?: ShapeKind +// radius?: number +// alpha?: number +// } + +// export interface ContinuousBarProps { +// x0?: string +// x1?: string +// y?: string +// fill?: string +// } + +// export enum DiscreteBarPositionKind { +// Stack = 'stack', +// Dodge = 'dodge', +// } + +// export interface DiscreteBarProps { +// x?: string +// y?: string +// fill?: string +// position?: DiscreteBarPositionKind +// } + +// export interface StepLineProps { +// x0?: string +// x1?: string +// y?: string +// } + +// export interface StepAreaProps { +// x0?: string +// x1?: string +// y?: string +// position?: AreaPositionKind +// } + +// export interface Bin2DProps { +// x?: string +// y?: string +// binWidth?: number +// binHeight?: number +// } diff --git a/ui/src/minard/utils/assert.ts b/ui/src/minard/utils/assert.ts new file mode 100644 index 00000000000..9eceafe9c48 --- /dev/null +++ b/ui/src/minard/utils/assert.ts @@ -0,0 +1,5 @@ +export const assert = (message: string, condition: boolean) => { + if (!condition) { + throw new Error(message) + } +} diff --git a/ui/src/minard/utils/clearCanvas.ts b/ui/src/minard/utils/clearCanvas.ts new file mode 100644 index 00000000000..64d793ebf60 --- /dev/null +++ b/ui/src/minard/utils/clearCanvas.ts @@ -0,0 +1,17 @@ +export const clearCanvas = ( + canvas: HTMLCanvasElement, + width: number, + height: number +) => { + const context = canvas.getContext('2d') + const dpRatio = window.devicePixelRatio || 1 + + // Configure canvas to draw on retina displays correctly + canvas.width = width * dpRatio + canvas.height = height * dpRatio + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + context.scale(dpRatio, dpRatio) + + context.clearRect(0, 0, width, height) +} diff --git a/ui/src/minard/utils/findHoveredRowIndices.tsx b/ui/src/minard/utils/findHoveredRowIndices.tsx new file mode 100644 index 00000000000..b73a79315ad --- /dev/null +++ b/ui/src/minard/utils/findHoveredRowIndices.tsx @@ -0,0 +1,23 @@ +import {range} from 'd3-array' + +export const findHoveredRowIndices = ( + xMinCol: number[], + xMaxCol: number[], + yMaxCol: number[], + dataX: number, + dataY: number +) => { + if (isNaN(dataX) || isNaN(dataY)) { + return null + } + + const hoveredRowIndices = range(0, xMinCol.length).filter( + i => xMinCol[i] <= dataX && xMaxCol[i] > dataX + ) + + if (!hoveredRowIndices.some(i => yMaxCol[i] >= dataY)) { + return null + } + + return hoveredRowIndices +} diff --git a/ui/src/minard/utils/plotEnvActions.ts b/ui/src/minard/utils/plotEnvActions.ts new file mode 100644 index 00000000000..1480357c1be --- /dev/null +++ b/ui/src/minard/utils/plotEnvActions.ts @@ -0,0 +1,74 @@ +import {Table, AestheticDataMappings} from 'src/minard' + +export type PlotAction = + | RegisterLayerAction + | UnregisterLayerAction + | SetDimensionsAction + | SetTableAction + | SetColorsAction + +interface RegisterLayerAction { + type: 'REGISTER_LAYER' + payload: { + layerKey: string + table: Table + aesthetics: AestheticDataMappings + colors?: string[] + } +} + +export const registerLayer = ( + layerKey: string, + table: Table, + aesthetics: AestheticDataMappings, + colors?: string[] +): RegisterLayerAction => ({ + type: 'REGISTER_LAYER', + payload: {layerKey, table, aesthetics, colors}, +}) + +interface UnregisterLayerAction { + type: 'UNREGISTER_LAYER' + payload: {layerKey: string} +} + +export const unregisterLayer = (layerKey: string): UnregisterLayerAction => ({ + type: 'UNREGISTER_LAYER', + payload: {layerKey}, +}) + +interface SetDimensionsAction { + type: 'SET_DIMENSIONS' + payload: {width: number; height: number} +} + +export const setDimensions = ( + width: number, + height: number +): SetDimensionsAction => ({ + type: 'SET_DIMENSIONS', + payload: {width, height}, +}) + +interface SetTableAction { + type: 'SET_TABLE' + payload: {table: Table} +} + +export const setTable = (table: Table): SetTableAction => ({ + type: 'SET_TABLE', + payload: {table}, +}) + +interface SetColorsAction { + type: 'SET_COLORS' + payload: {colors: string[]; layerKey?: string} +} + +export const setColors = ( + colors: string[], + layerKey?: string +): SetColorsAction => ({ + type: 'SET_COLORS', + payload: {colors, layerKey}, +}) diff --git a/ui/src/minard/utils/plotEnvReducer.ts b/ui/src/minard/utils/plotEnvReducer.ts new file mode 100644 index 00000000000..c3181109c50 --- /dev/null +++ b/ui/src/minard/utils/plotEnvReducer.ts @@ -0,0 +1,247 @@ +import {extent, ticks} from 'd3-array' +import {scaleLinear, scaleOrdinal} from 'd3-scale' +import {produce} from 'immer' +import chroma from 'chroma-js' + +import { + PlotEnv, + Layer, + PLOT_PADDING, + TICK_CHAR_WIDTH, + TICK_CHAR_HEIGHT, + TICK_PADDING_RIGHT, + TICK_PADDING_TOP, +} from 'src/minard' +import {PlotAction} from 'src/minard/utils/plotEnvActions' +import {assert} from 'src/minard/utils/assert' + +export const INITIAL_PLOT_ENV: PlotEnv = { + width: 0, + height: 0, + innerWidth: 0, + innerHeight: 0, + defaults: { + table: {columns: {}, columnTypes: {}}, + aesthetics: {}, + scales: {}, + }, + layers: {}, + xTicks: [], + yTicks: [], + xDomain: [], + yDomain: [], + hoverX: null, + hoverY: null, + margins: { + top: PLOT_PADDING, + right: PLOT_PADDING, + bottom: PLOT_PADDING, + left: PLOT_PADDING, + }, + dispatch: () => {}, +} + +export const plotEnvReducer = (state: PlotEnv, action: PlotAction): PlotEnv => + produce(state, draftState => { + switch (action.type) { + case 'REGISTER_LAYER': { + const {layerKey, table, aesthetics, colors} = action.payload + + draftState.layers[layerKey] = {table, aesthetics, colors, scales: {}} + + computeXYDomain(draftState) + computeXYLayout(draftState) + computeFillScales(draftState) + + return + } + + case 'UNREGISTER_LAYER': { + const {layerKey} = action.payload + + delete draftState.layers[layerKey] + + computeXYDomain(draftState) + computeXYLayout(draftState) + computeFillScales(draftState) + + return + } + + case 'SET_DIMENSIONS': { + const {width, height} = action.payload + + draftState.width = width + draftState.height = height + + computeXYLayout(draftState) + + return + } + + case 'SET_TABLE': { + draftState.defaults.table = action.payload.table + + return + } + + case 'SET_COLORS': { + const {colors, layerKey} = action.payload + + if (layerKey) { + draftState.layers[layerKey].colors = colors + } else { + draftState.defaults.colors = colors + } + + computeFillScales(draftState) + + return + } + } + }) + +const getCols = (state: PlotEnv, aestheticNames: string[]): any[][] => { + const {defaults, layers} = state + + const cols = [] + + for (const layer of [defaults, ...Object.values(layers)]) { + for (const aestheticName of aestheticNames) { + if (layer.aesthetics[aestheticName]) { + const colName = layer.aesthetics[aestheticName] + const col = layer.table + ? layer.table.columns[colName] + : defaults.table.columns[colName] + + cols.push(col) + } + } + } + + return cols +} + +const flatten = (arrays: any[][]): any[] => [].concat(...arrays) + +// TODO: Memoize computation by comparing to previous state +const computeXYDomain = (draftState: PlotEnv): void => { + draftState.xDomain = extent( + flatten(getCols(draftState, ['x', 'xMin', 'xMax']).map(col => extent(col))) + ) + + draftState.yDomain = extent( + flatten(getCols(draftState, ['y', 'yMin', 'yMax']).map(col => extent(col))) + ) +} + +const getTicks = ([d0, d1]: number[], length: number): string[] => { + const approxTickWidth = + Math.max(String(d0).length, String(d1).length) * TICK_CHAR_WIDTH + + const TICK_DENSITY = 0.5 + const numTicks = Math.round((length / approxTickWidth) * TICK_DENSITY) + const result = ticks(d0, d1, numTicks).map(t => String(t)) + + return result +} + +const computeXYLayout = (draftState: PlotEnv): void => { + const {width, height, xDomain, yDomain} = draftState + + draftState.xTicks = getTicks(xDomain, width) + draftState.yTicks = getTicks(yDomain, height) + + const yTickWidth = + Math.max(...draftState.yTicks.map(t => t.length)) * TICK_CHAR_WIDTH + + const margins = { + top: PLOT_PADDING, + right: PLOT_PADDING, + bottom: TICK_CHAR_HEIGHT + TICK_PADDING_TOP + PLOT_PADDING, + left: yTickWidth + TICK_PADDING_RIGHT + PLOT_PADDING, + } + + const innerWidth = width - margins.left - margins.right + const innerHeight = height - margins.top - margins.bottom + + draftState.margins = margins + draftState.innerWidth = innerWidth + draftState.innerHeight = innerHeight + + draftState.defaults.scales.x = scaleLinear() + .domain(xDomain) + .range([0, innerWidth]) + + draftState.defaults.scales.y = scaleLinear() + .domain(yDomain) + .range([innerHeight, 0]) +} + +const getColorScale = (domain: any[], colors: string[]) => { + const range = chroma + .scale(colors) + .mode('lch') + .colors(domain.length) + + const scale = scaleOrdinal() + .domain(domain) + .range(range) + + return scale +} + +const computeFillScales = (draftState: PlotEnv) => { + const defaultLayer = draftState.defaults + const layers = Object.values(draftState.layers) + + const getFillCol = (layer: Layer): any[] => { + const fillColName = layer.aesthetics.fill + + let fillCol: any[] + + if (layer.table && layer.table.columns[fillColName]) { + fillCol = layer.table.columns[fillColName] + } else if (defaultLayer.table) { + fillCol = defaultLayer.table.columns[fillColName] + } + + assert(`couldnt find column ${fillColName} for fill`, !!fillCol) + + return fillCol + } + + // Compute fill scales for layers that require their own scale + layers + .filter( + layer => layer.aesthetics.fill && layer.colors && layer.colors.length + ) + .forEach(layer => { + const fillDomain = [...new Set(getFillCol(layer))] + + layer.scales.fill = getColorScale(fillDomain, layer.colors) + }) + + // Compute the default scale + const layersUsingDefaultScale = layers.filter( + layer => layer.aesthetics.fill && (!layer.colors || !layer.colors.length) + ) + + const defaultScaleIsNeeded = + layersUsingDefaultScale.length || defaultLayer.aesthetics.fill + + if (!defaultScaleIsNeeded) { + return + } + + const fillDomain = new Set() + const fillDomainLayers = defaultLayer.aesthetics.fill + ? [defaultLayer, ...layersUsingDefaultScale] + : layersUsingDefaultScale + + fillDomainLayers + .map(getFillCol) + .forEach(col => col.forEach(d => fillDomain.add(d))) + + defaultLayer.scales.fill = getColorScale([...fillDomain], defaultLayer.colors) +} diff --git a/ui/src/minard/utils/stats.ts b/ui/src/minard/utils/stats.ts new file mode 100644 index 00000000000..98e346ac2a1 --- /dev/null +++ b/ui/src/minard/utils/stats.ts @@ -0,0 +1,115 @@ +import {extent, range, thresholdSturges} from 'd3-array' + +import {assert} from 'src/minard/utils/assert' +import { + AestheticDataMappings, + ColumnType, + Table, + HistogramPosition, +} from 'src/minard' + +export const bin = ( + xCol: number[], + xColType: ColumnType, + fillCol: string[], + fillColType: ColumnType, + binCount: number = 30, + position: HistogramPosition +): [Table, AestheticDataMappings] => { + assert( + `unsupported value column type "${xColType}"`, + xColType === ColumnType.Numeric || xColType === ColumnType.Temporal + ) + + const bins = createBins(xCol, binCount) + + for (let i = 0; i < xCol.length; i++) { + const x = xCol[i] + const fillDatum = fillCol ? fillCol[i] : 'default' + + // TODO: Use binary search + const bin = bins.find( + (b, i) => (x < b.max && x >= b.min) || i === bins.length - 1 + ) + + if (!bin.values[fillDatum]) { + bin.values[fillDatum] = 1 + } else { + bin.values[fillDatum] += 1 + } + } + + const fillData = fillCol ? [...new Set(fillCol)] : ['default'] + + const table = { + columns: {xMin: [], xMax: [], yMin: [], yMax: [], group: []}, + columnTypes: { + xMin: xColType, + xMax: xColType, + yMin: ColumnType.Numeric, + yMax: ColumnType.Numeric, + group: fillColType, + }, + } + + for (let i = 0; i < fillData.length; i++) { + const fillDatum = fillData[i] + + for (const bin of bins) { + let fillYMin = 0 + + if (position === HistogramPosition.Stacked) { + fillYMin = fillData + .slice(0, i) + .reduce((sum, f) => sum + (bin.values[f] || 0), 0) + } + + table.columns.xMin.push(bin.min) + table.columns.xMax.push(bin.max) + table.columns.yMin.push(fillYMin) + table.columns.yMax.push(fillYMin + (bin.values[fillDatum] || 0)) + table.columns.group.push(fillDatum) + } + } + + const mappings: any = { + xMin: 'xMin', + xMax: 'xMax', + yMin: 'yMin', + yMax: 'yMax', + fill: 'group', + } + + return [table, mappings] +} + +const createBins = ( + col: number[], + binCount: number +): Array<{max: number; min: number; values: {}}> => { + if (!binCount) { + binCount = thresholdSturges(col) + } + + const domain = extent(col) + const d0 = domain[0] + + let d1 = domain[1] + + if (d0 === d1) { + d1 = d0 + 1 + } + + const bins = range(d0, d1, (d1 - d0) / binCount).map(min => ({ + min, + values: {}, + })) + + for (let i = 0; i < bins.length - 1; i++) { + bins[i].max = bins[i + 1].min + } + + bins[bins.length - 1].max = d1 + + return bins +} diff --git a/ui/src/minard/utils/useLayoutStyle.ts b/ui/src/minard/utils/useLayoutStyle.ts new file mode 100644 index 00000000000..c09f84cb2b7 --- /dev/null +++ b/ui/src/minard/utils/useLayoutStyle.ts @@ -0,0 +1,18 @@ +import {useLayoutEffect, MutableRefObject, CSSProperties} from 'react' + +export const useLayoutStyle = ( + ref: MutableRefObject, + f: (el: HTMLElement) => CSSProperties +) => { + useLayoutEffect(() => { + if (!ref.current) { + return + } + + const style = f(ref.current) + + for (const [k, v] of Object.entries(style)) { + ref.current.style[k] = v + } + }) +} diff --git a/ui/src/minard/utils/useMousePos.ts b/ui/src/minard/utils/useMousePos.ts new file mode 100644 index 00000000000..ef53c03000a --- /dev/null +++ b/ui/src/minard/utils/useMousePos.ts @@ -0,0 +1,40 @@ +import {useState, useEffect, MutableRefObject} from 'react' + +export const useMousePos = ( + ref: MutableRefObject +): [number, number] => { + const [[x, y], setXY] = useState([null, null]) + + useEffect( + () => { + if (!ref.current) { + return + } + + const onMouseEnter = e => { + const {left, top} = ref.current.getBoundingClientRect() + + setXY([e.pageX - left, e.pageY - top]) + } + + const onMouseMove = onMouseEnter + + const onMouseLeave = () => { + setXY([null, null]) + } + + ref.current.addEventListener('mouseenter', onMouseEnter) + ref.current.addEventListener('mousemove', onMouseMove) + ref.current.addEventListener('mouseleave', onMouseLeave) + + return () => { + ref.current.removeEventListener('mouseenter', onMouseEnter) + ref.current.removeEventListener('mousemove', onMouseMove) + ref.current.removeEventListener('mouseleave', onMouseLeave) + } + }, + [ref.current] + ) + + return [x, y] +} diff --git a/ui/src/shared/components/Histogram.tsx b/ui/src/shared/components/Histogram.tsx new file mode 100644 index 00000000000..4d8ff841c6e --- /dev/null +++ b/ui/src/shared/components/Histogram.tsx @@ -0,0 +1,59 @@ +// Libraries +import React, {useMemo, SFC} from 'react' +import {connect} from 'react-redux' +import {Plot as MinardPlot, Histogram as MinardHistogram} from 'src/minard' + +// Components +import HistogramTooltip from 'src/shared/components/HistogramTooltip' + +// Utils +import {toMinardTable} from 'src/shared/utils/toMinardTable' +import {getActiveTimeMachine} from 'src/timeMachine/selectors' + +// Types +import {FluxTable} from 'src/types' +import {AppState} from 'src/types/v2' +import {HistogramView} from 'src/types/v2/dashboards' + +interface StateProps { + properties: HistogramView +} + +interface OwnProps { + width: number + height: number + tables: FluxTable[] +} + +type Props = OwnProps & StateProps + +const Histogram: SFC = props => { + const {tables, width, height} = props + const {x, fill, binCount, position, colors} = props.properties + const {table} = useMemo(() => toMinardTable(tables), [tables]) + const colorHexes = colors.map(c => c.hex) + + return ( + + {env => ( + + )} + + ) +} + +const mstp = (state: AppState) => { + const properties = getActiveTimeMachine(state).view + .properties as HistogramView + + return {properties} +} + +export default connect(mstp)(Histogram) diff --git a/ui/src/shared/components/HistogramTooltip.scss b/ui/src/shared/components/HistogramTooltip.scss new file mode 100644 index 00000000000..93e570041ca --- /dev/null +++ b/ui/src/shared/components/HistogramTooltip.scss @@ -0,0 +1,31 @@ +@import "src/style/modules"; + +.histogram-tooltip { + font-size: 12px; + font-family: $code-font; + background: $g2-kevlar; + border-radius: $ix-radius; + border: 1px solid $g3-castle; + padding: 10px; + color: $g14-chromium; +} + +.histogram-tooltip--table { + display: flex; + justify-content: space-between; + margin-top: 10px; +} + +.histogram-tooltip--fills { + margin-right: 15px; +} + +.histogram-tooltip--counts { + text-align: right; + flex: 1 1 0; +} + +.histogram-tooltip--column-header { + color: $g10-wolf; + margin-bottom: 5px; +} diff --git a/ui/src/shared/components/HistogramTooltip.tsx b/ui/src/shared/components/HistogramTooltip.tsx new file mode 100644 index 00000000000..5fcd47b4387 --- /dev/null +++ b/ui/src/shared/components/HistogramTooltip.tsx @@ -0,0 +1,46 @@ +import React, {SFC} from 'react' +import {HistogramTooltipProps} from 'src/minard' +import {format} from 'd3-format' + +import 'src/shared/components/HistogramTooltip.scss' + +const formatLarge = format('.4~s') +const formatSmall = format('.4~g') +const formatBin = n => (n < 1 && n > -1 ? formatSmall(n) : formatLarge(n)) + +const HistogramTooltip: SFC = ({ + fill, + xMin, + xMax, + counts, +}) => { + return ( +
+
+ {formatBin(xMin)} – {formatBin(xMax)} +
+
+ {fill && ( +
+
{fill}
+ {counts.map(({fill: fillDatum, color}) => ( +
+ {fillDatum} +
+ ))} +
+ )} +
+
count
+ {counts.map(({count, color}) => ( +
+ {count} +
+ ))} +
+
+
+ ) +} + +export default HistogramTooltip diff --git a/ui/src/shared/components/QueryViewSwitcher.tsx b/ui/src/shared/components/QueryViewSwitcher.tsx index d5140937787..982e259db8c 100644 --- a/ui/src/shared/components/QueryViewSwitcher.tsx +++ b/ui/src/shared/components/QueryViewSwitcher.tsx @@ -1,5 +1,6 @@ // Libraries import React, {PureComponent} from 'react' +import {AutoSizer} from 'react-virtualized' // Components import GaugeChart from 'src/shared/components/GaugeChart' @@ -7,6 +8,7 @@ import SingleStat from 'src/shared/components/SingleStat' import SingleStatTransform from 'src/shared/components/SingleStatTransform' import TableGraphs from 'src/shared/components/tables/TableGraphs' import DygraphContainer from 'src/shared/components/DygraphContainer' +import Histogram from 'src/shared/components/Histogram' // Types import { @@ -83,6 +85,14 @@ export default class QueryViewSwitcher extends PureComponent { ) + case ViewType.Histogram: + return ( + + {({width, height}) => ( + + )} + + ) default: return
} diff --git a/ui/src/shared/utils/toMinardTable.test.ts b/ui/src/shared/utils/toMinardTable.test.ts new file mode 100644 index 00000000000..0708b4560b5 --- /dev/null +++ b/ui/src/shared/utils/toMinardTable.test.ts @@ -0,0 +1,100 @@ +import {toMinardTable} from 'src/shared/utils/toMinardTable' +import {parseResponse} from 'src/shared/parsing/flux/response' + +describe('toMinardTable', () => { + test('with basic data', () => { + const CSV = `#group,false,false,true,true,false,false,true,true,true,true +#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,double,string,string,string,string +#default,_result,,,,,,,,, +,result,table,_start,_stop,_time,_value,_field,_measurement,cpu,host +,,0,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:33Z,10,usage_guest,cpu,cpu-total,oox4k.local +,,0,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:43Z,20,usage_guest,cpu,cpu-total,oox4k.local + +#group,false,false,true,true,false,false,true,true,true,true +#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,double,string,string,string,string +#default,_result,,,,,,,,, +,result,table,_start,_stop,_time,_value,_field,_measurement,cpu,host +,,1,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:33Z,30,usage_guest,cpu,cpu0,oox4k.local +,,1,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:43Z,40,usage_guest,cpu,cpu0,oox4k.local` + + const tables = parseResponse(CSV) + const actual = toMinardTable(tables) + const expected = { + schemaConflicts: [], + table: { + columnTypes: { + _field: 'categorical', + _measurement: 'categorical', + _start: 'temporal', + _stop: 'temporal', + _time: 'temporal', + _value: 'numeric', + cpu: 'categorical', + host: 'categorical', + table: 'numeric', + }, + columns: { + _field: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], + _measurement: ['cpu', 'cpu', 'cpu', 'cpu'], + _start: [1549064312524, 1549064312524, 1549064312524, 1549064312524], + _stop: [1549064342524, 1549064342524, 1549064342524, 1549064342524], + _time: [1549064313000, 1549064323000, 1549064313000, 1549064323000], + _value: [10, 20, 30, 40], + cpu: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], + host: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], + table: [0, 0, 1, 1], + }, + }, + } + + expect(actual).toEqual(expected) + }) + + test('with a schema conflict', () => { + const CSV = `#group,false,false,true,true,false,false,true,true,true,true +#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,double,string,string,string,string +#default,_result,,,,,,,,, +,result,table,_start,_stop,_time,_value,_field,_measurement,cpu,host +,,0,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:33Z,10,usage_guest,cpu,cpu-total,oox4k.local +,,0,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:43Z,20,usage_guest,cpu,cpu-total,oox4k.local + +#group,false,false,true,true,false,false,true,true,true,true +#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,string,string,string,string,string +#default,_result,,,,,,,,, +,result,table,_start,_stop,_time,_value,_field,_measurement,cpu,host +,,1,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:33Z,30,usage_guest,cpu,cpu0,oox4k.local +,,1,2019-02-01T23:38:32.524234Z,2019-02-01T23:39:02.524234Z,2019-02-01T23:38:43Z,40,usage_guest,cpu,cpu0,oox4k.local` + + const tables = parseResponse(CSV) + const actual = toMinardTable(tables) + const expected = { + schemaConflicts: ['_value'], + table: { + columnTypes: { + _field: 'categorical', + _measurement: 'categorical', + _start: 'temporal', + _stop: 'temporal', + _time: 'temporal', + _value: 'numeric', + cpu: 'categorical', + host: 'categorical', + table: 'numeric', + }, + columns: { + _field: ['usage_guest', 'usage_guest', 'usage_guest', 'usage_guest'], + _measurement: ['cpu', 'cpu', 'cpu', 'cpu'], + _start: [1549064312524, 1549064312524, 1549064312524, 1549064312524], + _stop: [1549064342524, 1549064342524, 1549064342524, 1549064342524], + _time: [1549064313000, 1549064323000, 1549064313000, 1549064323000], + _value: [10, 20, undefined, undefined], + cpu: ['cpu-total', 'cpu-total', 'cpu0', 'cpu0'], + host: ['oox4k.local', 'oox4k.local', 'oox4k.local', 'oox4k.local'], + table: [0, 0, 1, 1], + }, + }, + } + + expect(actual).toEqual(expected) + }) +}) diff --git a/ui/src/shared/utils/toMinardTable.ts b/ui/src/shared/utils/toMinardTable.ts new file mode 100644 index 00000000000..af6f0ddf19f --- /dev/null +++ b/ui/src/shared/utils/toMinardTable.ts @@ -0,0 +1,160 @@ +import {FluxTable} from 'src/types' +import {Table, ColumnType} from 'src/minard' + +export const SCHEMA_ERROR_MESSAGE = 'Encountered' + +interface ToMinardTableResult { + table: Table + schemaConflicts: string[] +} + +/* + Convert a series of `FluxTable`s to the table format used by the _Minard_ + visualization library. + + For example, given a series of Flux tables that look like this: + + column_a | column_b | column_c + ------------------------------ + 1 | "g" | 34 + 2 | "f" | 58 + 3 | "c" | 21 + + column_b | column_d + -------------------- + "h" | true + "g" | true + "c" | true + + This function will spread them out to a single wide table that looks like + this instead: + + column_a | column_b | column_c | column_d + ----------------------------------------- + 1 | "g" | 34 | + 2 | "f" | 58 | + 3 | "c" | 21 | + | "h" | | true + | "g" | | true + | "c" | | true + + + Note that: + + - If a value doesn't exist for a column, it is `undefined` in the result + - If a value does exist for a column but was specified as `null` in the Flux + response, it will be `null` in the result + - Values are coerced into approriate JavaScript types based on the Flux + `#datatype` annotation for the table + - If a resulting column has data of conflicting types, only the values for + the first data type encountered are kept + +*/ +export const toMinardTable = (tables: FluxTable[]): ToMinardTableResult => { + const columns = {} + const columnTypes = {} + const schemaConflicts = [] + + let k = 0 + + for (const table of tables) { + const header = table.data[0] + + if (!header) { + // Ignore empty tables + continue + } + + for (let j = 0; j < header.length; j++) { + const column = header[j] + + let columnConflictsSchema = false + + if (column === '' || column === 'result') { + // Ignore these columns + continue + } + + const columnType = toMinardColumnType(table.dataTypes[column]) + + if (columnTypes[column] && columnTypes[column] !== columnType) { + schemaConflicts.push(column) + columnConflictsSchema = true + } else if (!columnTypes[column]) { + columns[column] = [] + columnTypes[column] = columnType + } + + for (let i = 1; i < table.data.length; i++) { + // TODO: Refactor to treat each column as a `(name, dataType)` tuple + // rather than just a `name`. This will let us avoid dropping data due + // to schema conflicts + const value = columnConflictsSchema + ? undefined + : parseValue(table.data[i][j].trim(), columnType) + + columns[column][k + i - 1] = value + } + } + + k += table.data.length - 1 + } + + const result: ToMinardTableResult = { + table: {columns, columnTypes}, + schemaConflicts, + } + + return result +} + +const TO_MINARD_COLUMN_TYPE = { + boolean: ColumnType.Boolean, + unsignedLong: ColumnType.Numeric, + long: ColumnType.Numeric, + double: ColumnType.Numeric, + string: ColumnType.Categorical, + 'dateTime:RFC3339': ColumnType.Temporal, +} + +const toMinardColumnType = (fluxDataType: string): ColumnType => { + const columnType = TO_MINARD_COLUMN_TYPE[fluxDataType] + + if (!columnType) { + throw new Error(`encountered unknown Flux column type ${fluxDataType}`) + } + + return columnType +} + +const parseValue = (value: string, columnType: ColumnType): any => { + if (value === 'null') { + return null + } + + if (value === 'NaN') { + return NaN + } + + if (columnType === ColumnType.Boolean && value === 'true') { + return true + } + + if (columnType === ColumnType.Boolean && value === 'false') { + return false + } + + if (columnType === ColumnType.Categorical) { + return value + } + + if (columnType === ColumnType.Numeric) { + return Number(value) + } + + if (columnType === ColumnType.Temporal) { + return Date.parse(value) + } + + return null +} diff --git a/ui/src/shared/utils/view.ts b/ui/src/shared/utils/view.ts index ff5314ade9f..341a2b68089 100644 --- a/ui/src/shared/utils/view.ts +++ b/ui/src/shared/utils/view.ts @@ -1,7 +1,9 @@ import {ViewType, ViewShape} from 'src/types/v2' +import {HistogramPosition} from 'src/minard' import { XYView, XYViewGeom, + HistogramView, LinePlusSingleStatView, SingleStatView, TableView, @@ -13,6 +15,7 @@ import { InfluxLanguage, QueryEditMode, } from 'src/types/v2/dashboards' +import {DEFAULT_LINE_COLORS} from 'src/shared/constants/graphColorPalettes' import { DEFAULT_GAUGE_COLORS, DEFAULT_THRESHOLDS_LIST_COLORS, @@ -116,6 +119,20 @@ const NEW_VIEW_CREATORS = { geom: XYViewGeom.Line, }, }), + [ViewType.Histogram]: (): NewView => ({ + ...defaultView(), + properties: { + queries: [], + type: ViewType.Histogram, + x: '_value', + fill: 'table', + position: HistogramPosition.Stacked, + binCount: 30, + colors: DEFAULT_LINE_COLORS, + note: '', + showNoteWhenEmpty: false, + }, + }), [ViewType.SingleStat]: (): NewView => ({ ...defaultView(), properties: { diff --git a/ui/src/timeMachine/actions/index.ts b/ui/src/timeMachine/actions/index.ts index a289d290933..fb222a47577 100644 --- a/ui/src/timeMachine/actions/index.ts +++ b/ui/src/timeMachine/actions/index.ts @@ -15,6 +15,7 @@ import { } from 'src/types/v2/dashboards' import {TimeMachineTab} from 'src/types/v2/timeMachine' import {Color} from 'src/types/colors' +import {HistogramPosition} from 'src/minard' export type Action = | QueryBuilderAction @@ -54,6 +55,10 @@ export type Action = | SetFieldOptionsAction | SetTableOptionsAction | SetTimeFormatAction + | SetXAction + | SetFillAction + | SetBinCountAction + | SetHistogramPositionAction interface SetActiveTimeMachineAction { type: 'SET_ACTIVE_TIME_MACHINE' @@ -450,3 +455,45 @@ export const setTimeFormat = (timeFormat: string): SetTimeFormatAction => ({ type: 'SET_TIME_FORMAT', payload: {timeFormat}, }) + +interface SetXAction { + type: 'SET_X' + payload: {x: string} +} + +export const setX = (x: string): SetXAction => ({ + type: 'SET_X', + payload: {x}, +}) + +interface SetFillAction { + type: 'SET_FILL' + payload: {fill: string} +} + +export const setFill = (fill: string): SetFillAction => ({ + type: 'SET_FILL', + payload: {fill}, +}) + +interface SetBinCountAction { + type: 'SET_BIN_COUNT' + payload: {binCount: number} +} + +export const setBinCount = (binCount: number): SetBinCountAction => ({ + type: 'SET_BIN_COUNT', + payload: {binCount}, +}) + +interface SetHistogramPositionAction { + type: 'SET_HISTOGRAM_POSITION' + payload: {position: HistogramPosition} +} + +export const setHistogramPosition = ( + position: HistogramPosition +): SetHistogramPositionAction => ({ + type: 'SET_HISTOGRAM_POSITION', + payload: {position}, +}) diff --git a/ui/src/timeMachine/components/view_options/HistogramOptions.scss b/ui/src/timeMachine/components/view_options/HistogramOptions.scss new file mode 100644 index 00000000000..58ce3f9db73 --- /dev/null +++ b/ui/src/timeMachine/components/view_options/HistogramOptions.scss @@ -0,0 +1,6 @@ +@import "src/style/modules"; + +.column-option { + font-family: $code-font; + font-size: 11px; +} diff --git a/ui/src/timeMachine/components/view_options/HistogramOptions.tsx b/ui/src/timeMachine/components/view_options/HistogramOptions.tsx new file mode 100644 index 00000000000..b758b4c7548 --- /dev/null +++ b/ui/src/timeMachine/components/view_options/HistogramOptions.tsx @@ -0,0 +1,140 @@ +// Libraries +import React, {SFC} from 'react' +import {connect} from 'react-redux' + +// Components +import {Dropdown, Form, Grid, AutoInput} from 'src/clockface' +import ColorSchemeDropdown from 'src/shared/components/ColorSchemeDropdown' + +// Actions +import { + setX, + setFill, + setBinCount, + setHistogramPosition, + setColors, +} from 'src/timeMachine/actions' + +// Styles +import 'src/timeMachine/components/view_options/HistogramOptions.scss' + +// Types +import {HistogramPosition} from 'src/minard' +import {Color} from 'src/types/colors' + +interface DispatchProps { + onSetX: typeof setX + onSetFill: typeof setFill + onSetBinCount: typeof setBinCount + onSetPosition: typeof setHistogramPosition + onSetColors: typeof setColors +} + +interface OwnProps { + x: string + fill: string + position: HistogramPosition + binCount: number + colors: Color[] +} + +type Props = OwnProps & DispatchProps + +const NO_FILL = 'None' + +// TODO: These options are currently hardcoded +const HistogramOptions: SFC = props => { + const { + x, + fill, + position, + binCount, + colors, + onSetX, + onSetFill, + onSetPosition, + onSetBinCount, + onSetColors, + } = props + + return ( + +

Customize Histogram

+
Data
+ + + +
_value
+
+ +
_time
+
+
+
+ + + + None + + + Flux Group Key + + +
cpu
+
+ +
host
+
+ +
_measurement
+
+ +
_field
+
+
+
+
Options
+ + + + + + + Overlaid + + + Stacked + + + + + + +
+ ) +} + +const mdtp = { + onSetX: setX, + onSetFill: setFill, + onSetBinCount: setBinCount, + onSetPosition: setHistogramPosition, + onSetColors: setColors, +} + +export default connect<{}, DispatchProps, OwnProps>( + null, + mdtp +)(HistogramOptions) diff --git a/ui/src/timeMachine/components/view_options/OptionsSwitcher.tsx b/ui/src/timeMachine/components/view_options/OptionsSwitcher.tsx index 487bd41a70a..c41b59204c2 100644 --- a/ui/src/timeMachine/components/view_options/OptionsSwitcher.tsx +++ b/ui/src/timeMachine/components/view_options/OptionsSwitcher.tsx @@ -6,6 +6,7 @@ import LineOptions from 'src/timeMachine/components/view_options/LineOptions' import GaugeOptions from 'src/timeMachine/components/view_options/GaugeOptions' import SingleStatOptions from 'src/timeMachine/components/view_options/SingleStatOptions' import TableOptions from 'src/timeMachine/components/view_options/TableOptions' +import HistogramOptions from 'src/timeMachine/components/view_options/HistogramOptions' // Types import {ViewType, View, NewView} from 'src/types/v2' @@ -34,6 +35,8 @@ class OptionsSwitcher extends PureComponent { return case ViewType.Table: return + case ViewType.Histogram: + return default: return
} diff --git a/ui/src/timeMachine/components/view_options/ViewOptions.scss b/ui/src/timeMachine/components/view_options/ViewOptions.scss index 80143760c75..fb0054be615 100644 --- a/ui/src/timeMachine/components/view_options/ViewOptions.scss +++ b/ui/src/timeMachine/components/view_options/ViewOptions.scss @@ -19,14 +19,17 @@ } .view-options--header { - font-size: 16px; - font-weight: 500; @include no-user-select(); + font-weight: 500; + color: $g11-sidewalk; +} + +h4.view-options--header { + font-size: 16px; padding-top: $ix-marg-c; border-top: $ix-border solid $g5-pepper; margin-top: $ix-marg-b; margin-bottom: $ix-marg-c; - color: $g11-sidewalk; *:first-child > & { margin-top: 0; @@ -35,3 +38,10 @@ } } +h5.view-options--header { + font-size: 14px; + border-bottom: $ix-border solid $g5-pepper; + margin-top: $ix-marg-d; + margin-bottom: $ix-marg-c; + padding-bottom: $ix-marg-a; +} diff --git a/ui/src/timeMachine/constants/visGraphics.tsx b/ui/src/timeMachine/constants/visGraphics.tsx index d3eaf396cd5..2c42c39f784 100644 --- a/ui/src/timeMachine/constants/visGraphics.tsx +++ b/ui/src/timeMachine/constants/visGraphics.tsx @@ -359,6 +359,12 @@ export const VIS_GRAPHICS = [ name: 'Graph + Single Stat', graphic: GRAPHIC_SVGS[ViewType.LinePlusSingleStat], }, + { + type: ViewType.Histogram, + name: 'Histogram', + // TODO: Create a histogarm graphic + graphic: GRAPHIC_SVGS[ViewType.XY], + }, { type: ViewType.SingleStat, name: 'Single Stat', diff --git a/ui/src/timeMachine/reducers/index.ts b/ui/src/timeMachine/reducers/index.ts index 255b879ab2c..0859da423b6 100644 --- a/ui/src/timeMachine/reducers/index.ts +++ b/ui/src/timeMachine/reducers/index.ts @@ -266,6 +266,30 @@ export const timeMachineReducer = ( return setYAxis(state, {scale}) } + case 'SET_X': { + const {x} = action.payload + + return setViewProperties(state, {x}) + } + + case 'SET_FILL': { + const {fill} = action.payload + + return setViewProperties(state, {fill}) + } + + case 'SET_HISTOGRAM_POSITION': { + const {position} = action.payload + + return setViewProperties(state, {position}) + } + + case 'SET_BIN_COUNT': { + const {binCount} = action.payload + + return setViewProperties(state, {binCount}) + } + case 'SET_PREFIX': { const {prefix} = action.payload @@ -303,6 +327,7 @@ export const timeMachineReducer = ( case ViewType.Gauge: case ViewType.SingleStat: case ViewType.XY: + case ViewType.Histogram: return setViewProperties(state, {colors}) case ViewType.LinePlusSingleStat: return setViewProperties(state, { diff --git a/ui/src/types/v2/dashboards.ts b/ui/src/types/v2/dashboards.ts index d0dd2cd5d6a..cbfacbdffb9 100644 --- a/ui/src/types/v2/dashboards.ts +++ b/ui/src/types/v2/dashboards.ts @@ -1,3 +1,4 @@ +import {HistogramPosition} from 'src/minard' import {Color} from 'src/types/colors' import {Label} from '@influxdata/influx' import { @@ -122,6 +123,7 @@ export type ViewProperties = | MarkdownView | EmptyView | LogViewerView + | HistogramView export type QueryViewProperties = Extract< ViewProperties, @@ -218,6 +220,18 @@ export interface TableView { showNoteWhenEmpty: boolean } +export interface HistogramView { + type: ViewType.Histogram + queries: DashboardQuery[] + x: string + fill: string + position: HistogramPosition + binCount: number + colors: Color[] + note: string + showNoteWhenEmpty: boolean +} + export interface MarkdownView { type: ViewType.Markdown shape: ViewShape.ChronografV2 @@ -255,6 +269,7 @@ export enum ViewType { Table = 'table', Markdown = 'markdown', LogViewer = 'log-viewer', + Histogram = 'histogram', } export interface DashboardFile {