diff --git a/frontend/src/metabase/css/dashboard.css b/frontend/src/metabase/css/dashboard.css index 3dd80b94d77c0f7a7c66d7be72783e5c4e23fc81..4c0797d3742f1d18b54f5d520faa8807edf5d0d1 100644 --- a/frontend/src/metabase/css/dashboard.css +++ b/frontend/src/metabase/css/dashboard.css @@ -332,6 +332,12 @@ stroke-opacity: 1 !important; } +.dc-chart circle.bubble { + fill-opacity: 0.80; + stroke-width: 1; + stroke: white; +} + .enable-dots .dc-tooltip circle.dot:hover, .enable-dots .dc-tooltip circle.dot.hover { fill: currentColor; @@ -399,7 +405,13 @@ .mute-2 .sub._2 .dot, .mute-3 .sub._3 .dot, .mute-4 .sub._4 .dot, -.mute-5 .sub._5 .dot { opacity: 0.25; } +.mute-5 .sub._5 .dot, +.mute-0 .sub._0 .bubble, +.mute-1 .sub._1 .bubble, +.mute-2 .sub._2 .bubble, +.mute-3 .sub._3 .bubble, +.mute-4 .sub._4 .bubble, +.mute-5 .sub._5 .bubble { opacity: 0.25; } .mute-yl .dc-chart .axis.y, diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js index 3f0e5e66a2384183e4baf2e489fcfcba3a912cfb..346193d782b9f080913dc1bf284d5fd497a97de6 100644 --- a/frontend/src/metabase/lib/formatting.js +++ b/frontend/src/metabase/lib/formatting.js @@ -49,7 +49,7 @@ function formatMajorMinor(major, minor, options = {}) { } function formatTimeWithUnit(value, unit, options = {}) { - let m = parseTimestamp(value); + let m = parseTimestamp(value, unit); if (!m.isValid()) { return String(value); } @@ -67,7 +67,7 @@ function formatTimeWithUnit(value, unit, options = {}) { <div><span className="text-bold">{m.format("MMMM")}</span> {m.format("YYYY")}</div> : m.format("MMMM") + " " + m.format("YYYY"); case "year": // 2015 - return String(value); + return m.format("YYYY"); case "quarter": // Q1 - 2015 return formatMajorMinor(m.format("[Q]Q"), m.format("YYYY"), { ...options, majorWidth: 0 }); case "hour-of-day": // 12 AM @@ -97,7 +97,7 @@ export function formatValue(value, options = {}) { } else if (column && column.unit != null) { return formatTimeWithUnit(value, column.unit, options); } else if (isDate(column) || moment.isDate(value) || moment.isMoment(value) || moment(value, ["YYYY-MM-DD'T'HH:mm:ss.SSSZ"], true).isValid()) { - return parseTimestamp(value).format("LLLL"); + return parseTimestamp(value, column && column.unit).format("LLLL"); } else if (typeof value === "string") { return value; } else if (typeof value === "number") { diff --git a/frontend/src/metabase/lib/time.js b/frontend/src/metabase/lib/time.js index 532a19c1919d4ee1c25d7cdbae0431010722197f..dc544909fc12a1a32a6e8e63df7652e5d41628d7 100644 --- a/frontend/src/metabase/lib/time.js +++ b/frontend/src/metabase/lib/time.js @@ -2,11 +2,14 @@ import moment from "moment"; // only attempt to parse the timezone if we're sure we have one (either Z or ±hh:mm) // moment normally interprets the DD in YYYY-MM-DD as an offset :-/ -export function parseTimestamp(value) { +export function parseTimestamp(value, unit) { if (moment.isMoment(value)) { return value; } else if (typeof value === "string" && /(Z|[+-]\d\d:\d\d)$/.test(value)) { return moment.parseZone(value); + } else if (unit === "year") { + // workaround for https://github.com/metabase/metabase/issues/1992 + return moment().year(value).startOf("year"); } else { return moment.utc(value); } diff --git a/frontend/src/metabase/lib/visualization_settings.js b/frontend/src/metabase/lib/visualization_settings.js index 8518f90adc2d4e01885b71565038c2fe931dde04..1fa6cc09c9050fc270f8cc7042d3ada11d08aa4b 100644 --- a/frontend/src/metabase/lib/visualization_settings.js +++ b/frontend/src/metabase/lib/visualization_settings.js @@ -10,14 +10,20 @@ import { import { isNumeric, isDate, isMetric, isDimension, hasLatitudeAndLongitudeColumns } from "metabase/lib/schema_metadata"; import Query from "metabase/lib/query"; +import { capitalize } from "metabase/lib/formatting"; import { getCardColors, getFriendlyName } from "metabase/visualizations/lib/utils"; +import { dimensionIsTimeseries } from "metabase/visualizations/lib/timeseries"; +import { dimensionIsNumeric } from "metabase/visualizations/lib/numeric"; + import ChartSettingInput from "metabase/visualizations/components/settings/ChartSettingInput.jsx"; import ChartSettingInputNumeric from "metabase/visualizations/components/settings/ChartSettingInputNumeric.jsx"; import ChartSettingSelect from "metabase/visualizations/components/settings/ChartSettingSelect.jsx"; import ChartSettingToggle from "metabase/visualizations/components/settings/ChartSettingToggle.jsx"; +import ChartSettingFieldPicker from "metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx"; import ChartSettingFieldsPicker from "metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx"; +import ChartSettingColorPicker from "metabase/visualizations/components/settings/ChartSettingColorPicker.jsx"; import ChartSettingColorsPicker from "metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx"; import ChartSettingOrderedFields from "metabase/visualizations/components/settings/ChartSettingOrderedFields.jsx"; @@ -50,7 +56,24 @@ function getSeriesTitles([{ data: { rows, cols } }], vizSettings) { } } -function getDefaultDimensionsAndMetrics([{ data: { cols, rows } }]) { +function getDefaultColumns(series) { + if (series[0].card.display === "scatter") { + return getDefaultScatterColumns(series); + } else { + return getDefaultLineAreaBarColumns(series); + } +} + +function getDefaultScatterColumns([{ data: { cols, rows } }]) { + // TODO + return { + dimensions: [null], + metrics: [null], + bubble: null + }; +} + +function getDefaultLineAreaBarColumns([{ data: { cols, rows } }]) { let type = getChartTypeFromData(cols, rows, false); switch (type) { case DIMENSION_DIMENSION_METRIC: @@ -108,24 +131,36 @@ function getOptionFromColumn(col) { // const CURRENCIES = ["afn", "ars", "awg", "aud", "azn", "bsd", "bbd", "byr", "bzd", "bmd", "bob", "bam", "bwp", "bgn", "brl", "bnd", "khr", "cad", "kyd", "clp", "cny", "cop", "crc", "hrk", "cup", "czk", "dkk", "dop", "xcd", "egp", "svc", "eek", "eur", "fkp", "fjd", "ghc", "gip", "gtq", "ggp", "gyd", "hnl", "hkd", "huf", "isk", "inr", "idr", "irr", "imp", "ils", "jmd", "jpy", "jep", "kes", "kzt", "kpw", "krw", "kgs", "lak", "lvl", "lbp", "lrd", "ltl", "mkd", "myr", "mur", "mxn", "mnt", "mzn", "nad", "npr", "ang", "nzd", "nio", "ngn", "nok", "omr", "pkr", "pab", "pyg", "pen", "php", "pln", "qar", "ron", "rub", "shp", "sar", "rsd", "scr", "sgd", "sbd", "sos", "zar", "lkr", "sek", "chf", "srd", "syp", "tzs", "twd", "thb", "ttd", "try", "trl", "tvd", "ugx", "uah", "gbp", "usd", "uyu", "uzs", "vef", "vnd", "yer", "zwd"]; +import { normal } from "metabase/lib/colors"; + +const isAnyField = () => true; + const SETTINGS = { + "graph._dimension_filter": { + getDefault: ([{ card }]) => card.display === "scatter" ? isAnyField : isDimension + }, + "graph._metric_filter": { + getDefault: ([{ card }]) => card.display === "scatter" ? isNumeric : isMetric + }, "graph.dimensions": { section: "Data", title: "X-axis", widget: ChartSettingFieldsPicker, isValid: ([{ card, data }], vizSettings) => - columnsAreValid(card.visualization_settings["graph.dimensions"], data, isDimension) && - columnsAreValid(card.visualization_settings["graph.metrics"], data, isMetric), + columnsAreValid(card.visualization_settings["graph.dimensions"], data, vizSettings["graph._dimension_filter"]) && + columnsAreValid(card.visualization_settings["graph.metrics"], data, vizSettings["graph._metric_filter"]), getDefault: (series, vizSettings) => - getDefaultDimensionsAndMetrics(series).dimensions, + getDefaultColumns(series).dimensions, getProps: ([{ card, data }], vizSettings) => { const value = vizSettings["graph.dimensions"]; - const options = data.cols.filter(isDimension).map(getOptionFromColumn); + const options = data.cols.filter(vizSettings["graph._dimension_filter"]).map(getOptionFromColumn); return { options, - addAnother: (options.length > value.length && value.length < 2) ? "Add a series breakout..." : null + addAnother: (options.length > value.length && value.length < 2 && vizSettings["graph.metrics"].length < 2) ? + "Add a series breakout..." : null }; }, + readDependencies: ["graph._dimension_filter", "graph._metric_filter"], writeDependencies: ["graph.metrics"] }, "graph.metrics": { @@ -133,16 +168,35 @@ const SETTINGS = { title: "Y-axis", widget: ChartSettingFieldsPicker, isValid: ([{ card, data }], vizSettings) => - columnsAreValid(card.visualization_settings["graph.dimensions"], data, isDimension) && - columnsAreValid(card.visualization_settings["graph.metrics"], data, isMetric), + columnsAreValid(card.visualization_settings["graph.dimensions"], data, vizSettings["graph._dimension_filter"]) && + columnsAreValid(card.visualization_settings["graph.metrics"], data, vizSettings["graph._metric_filter"]), getDefault: (series, vizSettings) => - getDefaultDimensionsAndMetrics(series).metrics, + getDefaultColumns(series).metrics, getProps: ([{ card, data }], vizSettings) => { const value = vizSettings["graph.dimensions"]; - const options = data.cols.filter(isMetric).map(getOptionFromColumn); + const options = data.cols.filter(vizSettings["graph._metric_filter"]).map(getOptionFromColumn); + return { + options, + addAnother: options.length > value.length && vizSettings["graph.dimensions"].length < 2 ? + "Add another series..." : null + }; + }, + readDependencies: ["graph._dimension_filter", "graph._metric_filter"], + writeDependencies: ["graph.dimensions"] + }, + "scatter.bubble": { + section: "Data", + title: "Bubble size", + widget: ChartSettingFieldPicker, + isValid: ([{ card, data }], vizSettings) => + columnsAreValid([card.visualization_settings["scatter.bubble"]], data, isNumeric), + getDefault: (series) => + getDefaultColumns(series).bubble, + getProps: ([{ card, data }], vizSettings, onChange) => { + const options = data.cols.filter(isNumeric).map(getOptionFromColumn); return { options, - addAnother: options.length > value.length ? "Add another series..." : null + onRemove: vizSettings["scatter.bubble"] ? () => onChange(null) : null }; }, writeDependencies: ["graph.dimensions"] @@ -177,8 +231,84 @@ const SETTINGS = { (card.display === "area" && vizSettings["graph.metrics"].length > 1) ) }, + "graph.show_goal": { + section: "Display", + title: "Show goal", + widget: ChartSettingToggle, + default: false + }, + "graph.goal_value": { + section: "Display", + title: "Goal value", + widget: ChartSettingInputNumeric, + default: 0, + getHidden: (series, vizSettings) => vizSettings["graph.show_goal"] !== true, + readDependencies: ["graph.show_goal"] + }, + "line.missing": { + section: "Display", + title: "Replace missing values with", + widget: ChartSettingSelect, + default: "interpolate", + getProps: (series, vizSettings) => ({ + options: [ + { name: "Zero", value: "zero" }, + { name: "Nothing", value: "none" }, + { name: "Linear Interpolated", value: "interpolate" }, + ] + }) + }, + "graph.x_axis._is_timeseries": { + readDependencies: ["graph.dimensions"], + getDefault: ([{ data }], vizSettings) => + dimensionIsTimeseries(data, _.findIndex(data.cols, (c) => c.name === vizSettings["graph.dimensions"].filter(d => d)[0])) + }, + "graph.x_axis._is_numeric": { + readDependencies: ["graph.dimensions"], + getDefault: ([{ data }], vizSettings) => + dimensionIsNumeric(data, _.findIndex(data.cols, (c) => c.name === vizSettings["graph.dimensions"].filter(d => d)[0])) + }, + "graph.x_axis.scale": { + section: "Axes", + title: "X-axis scale", + widget: ChartSettingSelect, + default: "ordinal", + readDependencies: ["graph.x_axis._is_timeseries", "graph.x_axis._is_numeric"], + getDefault: (series, vizSettings) => + vizSettings["graph.x_axis._is_timeseries"] ? "timeseries" : + vizSettings["graph.x_axis._is_numeric"] ? "linear" : + "ordinal", + getProps: (series, vizSettings) => { + const options = []; + if (vizSettings["graph.x_axis._is_timeseries"]) { + options.push({ name: "Timeseries", value: "timeseries" }); + } + if (vizSettings["graph.x_axis._is_numeric"]) { + options.push({ name: "Linear", value: "linear" }); + options.push({ name: "Power", value: "pow" }); + options.push({ name: "Log", value: "log" }); + } + options.push({ name: "Ordinal", value: "ordinal" }); + return { options }; + } + }, + "graph.y_axis.scale": { + section: "Axes", + title: "Y-axis scale", + widget: ChartSettingSelect, + default: "linear", + getProps: (series, vizSettings) => ({ + options: [ + { name: "Linear", value: "linear" }, + { name: "Power", value: "pow" }, + { name: "Log", value: "log" } + ] + }) + }, "graph.colors": { section: "Display", + getTitle: ([{ card: { display } }]) => + capitalize(display === "scatter" ? "bubble" : display) + " Colors", widget: ChartSettingColorsPicker, readDependencies: ["graph.dimensions", "graph.metrics"], getDefault: ([{ card, data }], vizSettings) => { @@ -297,16 +427,21 @@ const SETTINGS = { }), }, "pie.show_legend": { - section: "Legend", + section: "Display", title: "Show legend", widget: ChartSettingToggle }, "pie.show_legend_perecent": { - section: "Legend", + section: "Display", title: "Show percentages in legend", widget: ChartSettingToggle, default: true }, + "pie.slice_threshold": { + section: "Display", + title: "Minimum slice percentage", + widget: ChartSettingInputNumeric + }, "scalar.locale": { title: "Separator style", widget: ChartSettingSelect, @@ -347,6 +482,18 @@ const SETTINGS = { title: "Multiply by a number", widget: ChartSettingInputNumeric }, + "progress.goal": { + section: "Display", + title: "Goal", + widget: ChartSettingInputNumeric, + default: 0 + }, + "progress.color": { + section: "Display", + title: "Color", + widget: ChartSettingColorPicker, + default: normal.green + }, "table.pivot": { title: "Pivot the table", widget: ChartSettingToggle, @@ -481,10 +628,12 @@ const SETTINGS_PREFIXES_BY_CHART_TYPE = { line: ["graph.", "line."], area: ["graph.", "line.", "stackable."], bar: ["graph.", "stackable."], + scatter: ["graph.", "scatter."], pie: ["pie."], scalar: ["scalar."], table: ["table."], - map: ["map."] + map: ["map."], + progress: ["progress."], } // alias legacy map types @@ -545,23 +694,25 @@ export function getSettings(series) { function getSettingWidget(id, vizSettings, series, onChangeSettings) { const settingDef = SETTINGS[id]; const value = vizSettings[id]; + const onChange = (value) => { + const newSettings = { [id]: value }; + for (const id of (settingDef.writeDependencies || [])) { + newSettings[id] = vizSettings[id]; + } + onChangeSettings(newSettings) + } return { ...settingDef, id: id, value: value, + title: settingDef.getTitle ? settingDef.getTitle(series, vizSettings) : settingDef.title, hidden: settingDef.getHidden ? settingDef.getHidden(series, vizSettings) : false, disabled: settingDef.getDisabled ? settingDef.getDisabled(series, vizSettings) : false, props: { ...(settingDef.props ? settingDef.props : {}), - ...(settingDef.getProps ? settingDef.getProps(series, vizSettings) : {}) + ...(settingDef.getProps ? settingDef.getProps(series, vizSettings, onChange) : {}) }, - onChange: (value) => { - const newSettings = { [id]: value }; - for (const id of (settingDef.writeDependencies || [])) { - newSettings[id] = vizSettings[id]; - } - onChangeSettings(newSettings) - } + onChange }; } @@ -569,5 +720,5 @@ export function getSettingsWidgets(series, onChangeSettings) { const vizSettings = getSettings(series); return getSettingIdsForSeries(series).map(id => getSettingWidget(id, vizSettings, series, onChangeSettings) - ); + ).filter(widget => widget.widget && !widget.hidden); } diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 62c66d8b19aa240c333a60d427f225ccc38b2318..506c04473332d0861a9b476121d86b44a8c0d82e 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -790,14 +790,16 @@ export const queryCompleted = createThunkAction(QUERY_COMPLETED, (card, queryRes let cardDisplay = card.display; // try a little logic to pick a smart display for the data - if (card.display !== "scalar" && + // TODO: less hard-coded rules for picking chart type + const isScalarVisualization = card.display === "scalar" || card.display === "progress"; + if (!isScalarVisualization && queryResult.data.rows && queryResult.data.rows.length === 1 && queryResult.data.columns.length === 1) { // if we have a 1x1 data result then this should always be viewed as a scalar cardDisplay = "scalar"; - } else if (card.display === "scalar" && + } else if (isScalarVisualization && queryResult.data.rows && (queryResult.data.rows.length > 1 || queryResult.data.columns.length > 1)) { // any time we were a scalar and now have more than 1x1 data switch to table view diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index bcbce84455132a9ca485a15694d395a51662dcf0..45cea1a8f1eb6c51de397b8b36bb77ff1146dabe 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import ReactDOM from "react-dom"; import { connect } from "react-redux"; import cx from "classnames"; import _ from "underscore"; @@ -150,6 +151,13 @@ export default class QueryBuilder extends Component { } } + componentDidUpdate() { + let viz = ReactDOM.findDOMNode(this.refs.viz); + if (viz) { + viz.style.opacity = 1.0; + } + } + componentWillUnmount() { window.removeEventListener('resize', this.handleResize); } @@ -158,6 +166,10 @@ export default class QueryBuilder extends Component { // Debounce the function to improve resizing performance. handleResize(e) { this.forceUpdateDebounced(); + let viz = ReactDOM.findDOMNode(this.refs.viz); + if (viz) { + viz.style.opacity = 0.2; + } } render() { @@ -196,7 +208,7 @@ export default class QueryBuilder extends Component { } </div> - <div id="react_qb_viz" className="flex z1"> + <div ref="viz" id="react_qb_viz" className="flex z1" style={{ "transition": "opacity 0.25s ease-in-out" }}> <QueryVisualization {...this.props}/> </div> </div> diff --git a/frontend/src/metabase/visualizations/PieChart.jsx b/frontend/src/metabase/visualizations/PieChart.jsx index e22d243dedd59cb25d4d735aec420b7a3f3d977b..7bb16fb948334c2bd86a86df2010138b4a774349 100644 --- a/frontend/src/metabase/visualizations/PieChart.jsx +++ b/frontend/src/metabase/visualizations/PieChart.jsx @@ -24,6 +24,8 @@ const PAD_ANGLE = (Math.PI / 180) * 1; // 1 degree in radians const SLICE_THRESHOLD = 1 / 360; // 1 degree in percentage const OTHER_SLICE_MIN_PERCENTAGE = 0.003; +const PERCENT_REGEX = /percent/i; + export default class PieChart extends Component { static displayName = "Pie"; static identifier = "pie"; @@ -62,10 +64,13 @@ export default class PieChart extends Component { const formatMetric = (metric, jsx = true) => formatValue(metric, { column: cols[metricIndex], jsx, majorWidth: 0 }) const formatPercent = (percent) => (100 * percent).toFixed(2) + "%" + const showPercentInTooltip = !PERCENT_REGEX.test(cols[metricIndex].name) && !PERCENT_REGEX.test(cols[metricIndex].display_name); + let total = rows.reduce((sum, row) => sum + row[metricIndex], 0); // use standard colors for up to 5 values otherwise use color harmony to help differentiate slices let sliceColors = Object.values(rows.length > 5 ? colors.harmony : colors.normal); + let sliceThreshold = typeof settings["pie.slice_threshold"] === "number" ? settings["pie.slice_threshold"] / 100 : SLICE_THRESHOLD; let [slices, others] = _.chain(rows) .map((row, index) => ({ @@ -74,7 +79,7 @@ export default class PieChart extends Component { percentage: row[metricIndex] / total, color: sliceColors[index % sliceColors.length] })) - .partition((d) => d.percentage > SLICE_THRESHOLD) + .partition((d) => d.percentage > sliceThreshold) .value(); let otherTotal = others.reduce((acc, o) => acc + o.value, 0); @@ -119,8 +124,7 @@ export default class PieChart extends Component { : [ { key: getFriendlyName(cols[dimensionIndex]), value: formatDimension(slices[index].key) }, { key: getFriendlyName(cols[metricIndex]), value: formatMetric(slices[index].value) }, - { key: "Percentage", value: formatPercent(slices[index].percentage) } - ] + ].concat(showPercentInTooltip ? [{ key: "Percentage", value: formatPercent(slices[index].percentage) }] : []) }); let value, title; diff --git a/frontend/src/metabase/visualizations/Progress.jsx b/frontend/src/metabase/visualizations/Progress.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9c6a7e766a6193dbd4b5634ab9c571b2bddf6082 --- /dev/null +++ b/frontend/src/metabase/visualizations/Progress.jsx @@ -0,0 +1,156 @@ +import React, { Component, PropTypes } from "react"; +import ReactDOM from "react-dom"; + +import { formatValue } from "metabase/lib/formatting"; +import Icon from "metabase/components/Icon.jsx"; +import IconBorder from "metabase/components/IconBorder.jsx"; + +import Color from "color"; + +const BORDER_RADIUS = 5; + +export default class Progress extends Component { + static displayName = "Progress"; + static identifier = "progress"; + static iconName = "number"; + + static minSize = { width: 3, height: 3 }; + + static isSensible(cols, rows) { + return rows.length === 1 && cols.length === 1; + } + + static checkRenderable(cols, rows) { + } + + componentDidMount() { + this.componentDidUpdate(); + } + + componentDidUpdate() { + const pointer = ReactDOM.findDOMNode(this.refs.pointer); + const label = ReactDOM.findDOMNode(this.refs.label); + const container = ReactDOM.findDOMNode(this.refs.container); + + if (this.props.gridSize && this.props.gridSize.height < 4) { + pointer.parentNode.style.display = "none"; + label.parentNode.style.display = "none"; + // no need to do the rest of the repositioning + return; + } else { + pointer.parentNode.style.display = null; + label.parentNode.style.display = null; + } + + // reset the pointer transform for these computations + pointer.style.transform = null; + + // position the label + const containerWidth = container.offsetWidth; + const labelWidth = label.offsetWidth; + const pointerWidth = pointer.offsetWidth; + const pointerCenter = pointer.offsetLeft + pointerWidth / 2; + const minOffset = (-pointerWidth / 2) + BORDER_RADIUS + if (pointerCenter - labelWidth / 2 < minOffset) { + label.style.left = minOffset + "px"; + label.style.right = null + } else if (pointerCenter + labelWidth / 2 > containerWidth - minOffset) { + label.style.left = null + label.style.right = minOffset + "px"; + } else { + label.style.left = (pointerCenter - labelWidth / 2) + "px"; + label.style.right = null; + } + + // shift pointer at ends inward to line up with border radius + if (pointerCenter < BORDER_RADIUS) { + pointer.style.transform = "translate(" + BORDER_RADIUS + "px,0)"; + } else if (pointerCenter > containerWidth - 5) { + pointer.style.transform = "translate(-" + BORDER_RADIUS + "px,0)"; + } + } + + render() { + const { series: [{ data: { rows } }], settings } = this.props; + const value = rows[0][0]; + const goal = settings["progress.goal"] || 0; + + const mainColor = settings["progress.color"]; + const lightColor = Color(mainColor).lighten(0.25).rgbString(); + const darkColor = Color(mainColor).darken(0.30).rgbString(); + + const progressColor = mainColor; + const restColor = value > goal ? darkColor : lightColor; + const arrowColor = value > goal ? darkColor : mainColor; + + const barPercent = Math.max(0, value < goal ? value / goal : goal / value); + const arrowPercent = Math.max(0, value < goal ? value / goal : 1); + + let barMessage; + if (value === goal) { + barMessage = "Goal met"; + } else if (value > goal) { + barMessage = "Goal exceeded"; + } + + return ( + <div className="full-height flex layout-centered"> + <div className="flex-full full-height flex flex-column justify-center" style={{ padding: 10, paddingTop: 0 }}> + <div + ref="container" + className="relative text-bold text-grey-4" + style={{ height: 20 }} + > + <div + ref="label" + style={{ position: "absolute" }} + > + {formatValue(value, { comma: true })} + </div> + </div> + <div className="relative" style={{ height: 10, marginBottom: 5 }}> + <div + ref="pointer" + style={{ + width: 0, + height: 0, + position: "absolute", + left: (arrowPercent * 100) + "%", + marginLeft: -10, + borderLeft: "10px solid transparent", + borderRight: "10px solid transparent", + borderTop: "10px solid " + arrowColor + }} + /> + </div> + <div className="relative" style={{ + backgroundColor: restColor, + borderRadius: BORDER_RADIUS, + height: "25%", + maxHeight: 65, + overflow: "hidden" + }}> + <div style={{ + backgroundColor: progressColor, + width: (barPercent * 100) + "%", + height: "100%" + }} + /> + { barMessage && + <div className="flex align-center absolute spread text-white text-bold px2"> + <IconBorder borderWidth={2}> + <Icon name="check" size={14} /> + </IconBorder> + <div className="pl2">{barMessage}</div> + </div> + } + </div> + <div className="mt1"> + <span className="float-left">0</span> + <span className="float-right">Goal {formatValue(goal, { comma: true })}</span> + </div> + </div> + </div> + ); + } +} diff --git a/frontend/src/metabase/visualizations/ScatterPlot.jsx b/frontend/src/metabase/visualizations/ScatterPlot.jsx new file mode 100644 index 0000000000000000000000000000000000000000..76de5e1c6f2bb980b84979f500160a9f8274a222 --- /dev/null +++ b/frontend/src/metabase/visualizations/ScatterPlot.jsx @@ -0,0 +1,10 @@ +import React, { Component, PropTypes } from "react"; + +import LineAreaBarChart from "./components/LineAreaBarChart.jsx"; + +export default class ScatterPlot extends LineAreaBarChart { + static displayName = "Scatter"; + static identifier = "scatter"; + static iconName = "line"; + static noun = "scatter plot"; +} diff --git a/frontend/src/metabase/visualizations/components/CardRenderer.jsx b/frontend/src/metabase/visualizations/components/CardRenderer.jsx index c65d50a7c890fec736400a0ba27a3091a0ae650d..4a84fd0521f69fa716b64bd186ff1575c6c641cc 100644 --- a/frontend/src/metabase/visualizations/components/CardRenderer.jsx +++ b/frontend/src/metabase/visualizations/components/CardRenderer.jsx @@ -14,8 +14,8 @@ import cx from "classnames"; export default class CardRenderer extends Component { static propTypes = { series: PropTypes.array.isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, + width: PropTypes.number, + height: PropTypes.number, renderer: PropTypes.func.isRequired, onRenderError: PropTypes.func.isRequired, className: PropTypes.string diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx index a54020fcbfbd8dfeea9c5090e6d78aaf1744f345..10374c314c2bcb995a6a51cf1928b994bea71879 100644 --- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx +++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx @@ -27,7 +27,7 @@ const ChartSettingsTabs = ({ tabs, selectTab, activeTab}) => const Widget = ({ title, hidden, disabled, widget, value, onChange, props }) => { const W = widget; return ( - <div className={cx("mb3", { hide: hidden, disable: disabled })}> + <div className={cx("mb2", { hide: hidden, disable: disabled })}> { title && <h4 className="mb1">{title}</h4> } { W && <W value={value} onChange={onChange} {...props}/> } </div> diff --git a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx index 45203999085f2c950d3457ceaa49bfc869916385..09d9aac7eb1af4626a128d6c962038fe66181990 100644 --- a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx +++ b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx @@ -18,6 +18,12 @@ export default class ChartTooltip extends Component { static defaultProps = { }; + componentWillReceiveProps({ hovered }) { + if (hovered && !Array.isArray(hovered.data)) { + console.warn("hovered.data should be an array of { key, value, col }", hovered.data); + } + } + render() { const { series, hovered } = this.props; if (!(hovered && hovered.data && ((hovered.element && document.contains(hovered.element)) || hovered.event))) { @@ -33,10 +39,16 @@ export default class ChartTooltip extends Component { <table className="py1 px2"> <tbody> { Array.isArray(hovered.data) ? - hovered.data.map(({ key, value }, index) => + hovered.data.map(({ key, value, col }, index) => <tr key={index}> <td className="text-light text-right">{key}:</td> - <td className="pl1 text-bold text-left">{value}</td> + <td className="pl1 text-bold text-left"> + { col ? + formatValue(value, { column: col, jsx: true, majorWidth: 0 }) + : + value + } + </td> </tr> ) : diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx index 1664d4664fc61c196292d47979b3cf3b2cb7c499..5e9e6fe99c5c555d1c22f7bbceee837bba2ec799 100644 --- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx +++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx @@ -99,12 +99,14 @@ export default class LineAreaBarChart extends Component { _.findIndex(cols, (col) => col.name === metricName) ); + const bubbleIndex = settings["scatter.bubble"] && _.findIndex(cols, (col) => col.name === settings["scatter.bubble"]); + const extraIndexes = bubbleIndex && bubbleIndex >= 0 ? [bubbleIndex] : []; + if (dimensions.length > 1) { const dataset = crossfilter(rows); const [dimensionIndex, seriesIndex] = dimensionIndexes; - const rowIndexes = [dimensionIndex].concat(metricIndexes); + const rowIndexes = [dimensionIndex].concat(metricIndexes, extraIndexes); const seriesGroup = dataset.dimension(d => d[seriesIndex]).group() - nextState.series = seriesGroup.reduce( (p, v) => p.concat([rowIndexes.map(i => v[i])]), (p, v) => null, () => [] @@ -124,6 +126,7 @@ export default class LineAreaBarChart extends Component { nextState.series = metricIndexes.map(metricIndex => { const col = cols[metricIndex]; + const rowIndexes = [dimensionIndex].concat(metricIndex, extraIndexes); return { card: { ...s.card, @@ -131,8 +134,10 @@ export default class LineAreaBarChart extends Component { name: getFriendlyName(col) }, data: { - rows: rows.map(row => [row[dimensionIndex], row[metricIndex]]), - cols: [cols[dimensionIndex], s.data.cols[metricIndex]] + rows: rows.map(row => + rowIndexes.map(i => row[i]) + ), + cols: rowIndexes.map(i => s.data.cols[i]) } }; }); diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker.jsx new file mode 100644 index 0000000000000000000000000000000000000000..631b8506451958f3f902ab703e9374d4aaa307f8 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker.jsx @@ -0,0 +1,47 @@ +import React, { Component, PropTypes } from "react"; + +import { normal } from 'metabase/lib/colors' +const DEFAULT_COLOR_HARMONY = Object.values(normal); + +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; + +export default class ChartSettingColorPicker extends Component { + render() { + const { value, onChange, title } = this.props; + return ( + <div className="flex align-center"> + <PopoverWithTrigger + ref="colorPopover" + hasArrow={false} + tetherOptions={{ + attachment: 'middle left', + targetAttachment: 'middle right', + targetOffset: '0 0', + constraints: [{ to: 'window', attachment: 'together', pin: ['left', 'right']}] + }} + triggerElement={ + <span className="ml1 mr2 bordered inline-block cursor-pointer" style={{ padding: 4, borderRadius: 3 }}> + <div style={{ width: 15, height: 15, backgroundColor: value }} /> + </span> + } + > + <ol className="p1"> + {DEFAULT_COLOR_HARMONY.map((color, colorIndex) => + <li + key={colorIndex} + className="CardSettings-colorBlock" + style={{ backgroundColor: color }} + onClick={() => { + onChange(color); + this.refs.colorPopover.close(); + }} + ></li> + )} + </ol> + </PopoverWithTrigger> + + <span className="text-bold">{title}</span> + </div> + ); + } +} diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx index f5fd68c742d05f470afced27bb26c7936b50753a..9ca6cae19b3b9397fc44162c89f360e0990e87a2 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx @@ -1,9 +1,6 @@ import React, { Component, PropTypes } from "react"; -import { normal } from 'metabase/lib/colors' -const DEFAULT_COLOR_HARMONY = Object.values(normal); - -import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; +import ChartSettingColorPicker from "./ChartSettingColorPicker.jsx"; export default class ChartSettingColorsPicker extends Component { render() { @@ -11,39 +8,12 @@ export default class ChartSettingColorsPicker extends Component { return ( <div> { seriesTitles.map((title, index) => - <div key={index} className="flex align-center"> - <PopoverWithTrigger - ref="colorPopover" - hasArrow={false} - tetherOptions={{ - attachment: 'middle left', - targetAttachment: 'middle right', - targetOffset: '0 0', - constraints: [{ to: 'window', attachment: 'together', pin: ['left', 'right']}] - }} - triggerElement={ - <span className="ml1 mr2 bordered inline-block cursor-pointer" style={{ padding: 4, borderRadius: 3 }}> - <div style={{ width: 15, height: 15, backgroundColor: value[index] }} /> - </span> - } - > - <ol className="p1"> - {DEFAULT_COLOR_HARMONY.map((color, colorIndex) => - <li - key={colorIndex} - className="CardSettings-colorBlock" - style={{ backgroundColor: color }} - onClick={() => { - onChange([...value.slice(0, index), color, ...value.slice(index + 1)]); - this.refs.colorPopover.close(); - }} - ></li> - )} - </ol> - </PopoverWithTrigger> - - <span className="text-bold">{title}</span> - </div> + <ChartSettingColorPicker + key={index} + value={value[index]} + onChange={(color) => onChange([...value.slice(0, index), color, ...value.slice(index + 1)])} + title={title} + /> )} </div> ); diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2c7e9896d4e543749099ae6338367e71e13db6a1 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx @@ -0,0 +1,29 @@ +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon"; +import cx from "classnames"; + +import ChartSettingSelect from "./ChartSettingSelect.jsx"; + +const ChartSettingFieldPicker = ({ value, options, onChange, onRemove }) => + <div className="flex align-center"> + <ChartSettingSelect + value={value} + options={options} + onChange={onChange} + placeholder="Select a field" + placeholderNoOptions="No valid fields" + isInitiallyOpen={value === undefined} + /> + <Icon + name="close" + className={cx("ml1 text-grey-4 text-brand-hover cursor-pointer", { + "disabled hidden": !onRemove + })} + width={12} height={12} + onClick={onRemove} + /> + </div> + + +export default ChartSettingFieldPicker; diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx index 45c9e18e99b815456373caab3d762f96a606019d..7e6ea5cc982dc8d9d68ae9788636d9212a71a23a 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx @@ -1,39 +1,30 @@ import React, { Component, PropTypes } from "react"; -import Icon from "metabase/components/Icon"; -import cx from "classnames"; +import ChartSettingFieldPicker from "./ChartSettingFieldPicker.jsx"; -import ChartSettingSelect from "./ChartSettingSelect.jsx"; - -const ChartSettingFieldsPicker = ({ value = [], onChange, options, addAnother }) => +const ChartSettingFieldsPicker = ({ value = [], options, onChange, addAnother }) => <div> { Array.isArray(value) ? value.map((v, index) => - <div key={index} className="flex align-center"> - <ChartSettingSelect - value={v} - options={options} - onChange={(v) => { - let newValue = [...value]; - // this swaps the position of the existing value - let existingIndex = value.indexOf(v); - if (existingIndex >= 0) { - newValue.splice(existingIndex, 1, value[index]); - } - // replace with the new value - newValue.splice(index, 1, v); - onChange(newValue); - }} - isInitiallyOpen={v === undefined} - /> - <Icon - name="close" - className={cx("ml1 text-grey-4 text-brand-hover cursor-pointer", { - "disabled hidden": value.filter(v => v != null).length < 2 - })} - width={12} height={12} - onClick={() => onChange([...value.slice(0, index), ...value.slice(index + 1)])} - /> - </div> + <ChartSettingFieldPicker + key={index} + value={v} + options={options} + onChange={(v) => { + let newValue = [...value]; + // this swaps the position of the existing value + let existingIndex = value.indexOf(v); + if (existingIndex >= 0) { + newValue.splice(existingIndex, 1, value[index]); + } + // replace with the new value + newValue.splice(index, 1, v); + onChange(newValue); + }} + onRemove={value.filter(v => v != null).length > 1 || (value.length > 1 && v == null) ? + () => onChange([...value.slice(0, index), ...value.slice(index + 1)]) : + null + } + /> ) : <span className="text-error">error</span>} { addAnother && <div className="mt1"> diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx index c2828825ffd6e941c5d70a8f37ccc0cae1d914b3..b8a2875d4fcf9d591e1caf2860f7cb5fad5d1378 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx @@ -3,15 +3,17 @@ import React, { Component, PropTypes } from "react"; import Select from "metabase/components/Select.jsx"; import _ from "underscore"; +import cx from "classnames"; -const ChartSettingSelect = ({ value, onChange, options = [], isInitiallyOpen }) => +const ChartSettingSelect = ({ value, onChange, options = [], isInitiallyOpen, className, placeholder, placeholderNoOptions }) => <Select - className="block flex-full" + className={cx(className, "block flex-full", { disabled: options.length === 0 || (options.length === 1 && options[0].value === value) })} value={_.findWhere(options, { value })} options={options} optionNameFn={(o) => o.name} optionValueFn={(o) => o.value} onChange={onChange} + placeholder={options.length === 0 ? placeholderNoOptions : placeholder} isInitiallyOpen={isInitiallyOpen} /> diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js index f0912c8a1c6eeea9c661a3592c451dad0609b8b5..e0fa06a869645327747f0e89f9c60c52702a382f 100644 --- a/frontend/src/metabase/visualizations/index.js +++ b/frontend/src/metabase/visualizations/index.js @@ -1,11 +1,13 @@ import Scalar from "./Scalar.jsx"; +import Progress from "./Progress.jsx"; import Table from "./Table.jsx"; import LineChart from "./LineChart.jsx"; import BarChart from "./BarChart.jsx"; import PieChart from "./PieChart.jsx"; import AreaChart from "./AreaChart.jsx"; import MapViz from "./Map.jsx"; +import ScatterPlot from "./ScatterPlot.jsx"; const visualizations = new Map(); const aliases = new Map(); @@ -28,14 +30,15 @@ export function registerVisualization(visualization) { } registerVisualization(Scalar); +registerVisualization(Progress); registerVisualization(Table); registerVisualization(LineChart); registerVisualization(BarChart); -registerVisualization(PieChart); registerVisualization(AreaChart); +registerVisualization(ScatterPlot); +registerVisualization(PieChart); registerVisualization(MapViz); - import { enableVisualizationEasterEgg } from "./lib/utils"; import XKCDChart from "./XKCDChart.jsx"; import LineAreaBarChart from "./components/LineAreaBarChart.jsx"; diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index d36b1a32cfeea1cb885a39b41bc2521f58869918..49c40f3565a9980716bcbd024d957e86d1f42d87 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -2,6 +2,7 @@ import crossfilter from "crossfilter"; import d3 from "d3"; import dc from "dc"; import moment from "moment"; +import _ from "underscore"; import { getAvailableCanvasWidth, @@ -13,11 +14,14 @@ import { import { minTimeseriesUnit, - dimensionIsTimeseries, computeTimeseriesDataInverval, computeTimeseriesTicksInterval } from "./timeseries"; +import { + computeNumericDataInverval +} from "./numeric"; + import { determineSeriesIndexFromElement } from "./tooltip"; import { colorShades } from "./utils"; @@ -51,43 +55,26 @@ function getDcjsChartType(cardType) { switch (cardType) { case "line": return "lineChart"; case "area": return "lineChart"; - case "bar": return "barChart"; + case "bar": return "barChart"; + case "scatter": return "bubbleChart"; default: return "barChart"; } } -function initializeChart(card, element, chartType = getDcjsChartType(card.display)) { - // create the chart - let chart = dc[chartType](element); - - // set width and height - chart = applyChartBoundary(chart, element); - - // disable animations - chart.transitionDuration(0); - - return chart; -} - function applyChartBoundary(chart, element) { return chart .width(getAvailableCanvasWidth(element)) .height(getAvailableCanvasHeight(element)); } -function applyChartTimeseriesXAxis(chart, settings, series, xValues) { +function applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xInterval) { // setup an x-axis where the dimension is a timeseries let dimensionColumn = series[0].data.cols[0]; - let unit = minTimeseriesUnit(series.map(s => s.data.cols[0].unit)); - // compute the data interval - let dataInterval = computeTimeseriesDataInverval(xValues, unit); + let dataInterval = xInterval; let tickInterval = dataInterval; - // compute the domain - let xDomain = d3.extent(xValues); - if (settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel(settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn)); } @@ -99,21 +86,21 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues) { } chart.xAxis().tickFormat(timestamp => { - // these dates are in the browser's timezone, change to UTC + // HACK: these dates are in the browser's timezone, change to UTC let timestampUTC = moment(timestamp).format().replace(/[+-]\d+:\d+$/, "Z"); return formatValue(timestampUTC, { column: dimensionColumn }) }); // Compute a sane interval to display based on the data granularity, domain, and chart width - tickInterval = computeTimeseriesTicksInterval(xValues, unit, chart.width(), MIN_PIXELS_PER_TICK.x); + tickInterval = computeTimeseriesTicksInterval(xDomain, dataInterval, chart.width(), MIN_PIXELS_PER_TICK.x, ); chart.xAxis().ticks(d3.time[tickInterval.interval], tickInterval.count); } else { chart.xAxis().ticks(0); } // pad the domain slightly to prevent clipping - xDomain[0] = moment(xDomain[0]).subtract(dataInterval.count * 0.75, dataInterval.interval); - xDomain[1] = moment(xDomain[1]).add(dataInterval.count * 0.75, dataInterval.interval); + xDomain[0] = moment(xDomain[0]).subtract(dataInterval.count * 0.75, dataInterval.interval).toDate(); + xDomain[1] = moment(xDomain[1]).add(dataInterval.count * 0.75, dataInterval.interval).toDate(); // set the x scale chart.x(d3.time.scale.utc().domain(xDomain));//.nice(d3.time[dataInterval.interval])); @@ -122,6 +109,44 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues) { chart.xUnits((start, stop) => Math.ceil(1 + moment(stop).diff(start, dataInterval.interval) / dataInterval.count)); } +function applyChartQuantitativeXAxis(chart, settings, series, xValues, xDomain, xInterval) { + const dimensionColumn = series[0].data.cols[0]; + + if (settings["graph.x_axis.labels_enabled"]) { + chart.xAxisLabel(settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn)); + } + if (settings["graph.x_axis.axis_enabled"]) { + chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]); + adjustTicksIfNeeded(chart.xAxis(), chart.width(), MIN_PIXELS_PER_TICK.x); + + chart.xAxis().tickFormat(d => formatValue(d, { column: dimensionColumn })); + } else { + chart.xAxis().ticks(0); + chart.xAxis().tickFormat(''); + } + + let scale; + if (settings["graph.x_axis.scale"] === "pow") { + scale = d3.scale.pow().exponent(0.5); + } else if (settings["graph.x_axis.scale"] === "log") { + scale = d3.scale.log().base(Math.E); + if (!((xDomain[0] < 0 && xDomain[1] < 0) || (xDomain[0] > 0 && xDomain[1] > 0))) { + throw "X-axis must not cross 0 when using log scale."; + } + } else { + scale = d3.scale.linear(); + } + + // pad the domain slightly to prevent clipping + xDomain = [ + xDomain[0] - xInterval * 0.75, + xDomain[1] + xInterval * 0.75 + ]; + + chart.x(scale.domain(xDomain)) + .xUnits(dc.units.fp.precision(xInterval)); +} + function applyChartOrdinalXAxis(chart, settings, series, xValues) { const dimensionColumn = series[0].data.cols[0]; @@ -154,51 +179,99 @@ function applyChartOrdinalXAxis(chart, settings, series, xValues) { .xUnits(dc.units.ordinal); } -function applyChartYAxis(chart, settings, series, yAxisSplit) { - - if (settings["graph.y_axis.labels_enabled"]) { +function applyChartYAxis(chart, settings, series, yExtent, axisName) { + let axis; + if (axisName === "left") { + axis = { + scale: (...args) => chart.y(...args), + axis: (...args) => chart.yAxis(...args), + label: (...args) => chart.yAxisLabel(...args), + setting: (name) => settings["graph.y_axis." + name] + }; + } else if (axisName === "right") { + axis = { + scale: (...args) => chart.rightY(...args), + axis: (...args) => chart.rightYAxis(...args), + label: (...args) => chart.rightYAxisLabel(...args), + setting: (name) => settings["graph.y_axis." + name] // TODO: right axis settings + }; + } + + if (axis.setting("labels_enabled")) { // left - if (settings["graph.y_axis.title_text"]) { - chart.yAxisLabel(settings["graph.y_axis.title_text"]); - } else if (yAxisSplit[0].length === 1) { - chart.yAxisLabel(getFriendlyName(series[yAxisSplit[0][0]].data.cols[1])); - } - // right - if (yAxisSplit.length > 1 && yAxisSplit[1].length === 1) { - chart.rightYAxisLabel(getFriendlyName(series[yAxisSplit[1][0]].data.cols[1])); + if (axis.setting("title_text")) { + axis.label(axis.setting("title_text")); + } else { + // only use the column name if all in the series are the same + const labels = _.uniq(series.map(s => getFriendlyName(s.data.cols[1]))); + if (labels.length === 1) { + axis.label(labels[0]); + } } } - if (settings["graph.y_axis.axis_enabled"]) { + if (axis.setting("axis_enabled")) { chart.renderHorizontalGridLines(true); + adjustTicksIfNeeded(axis.axis(), chart.height(), MIN_PIXELS_PER_TICK.y); + } else { + axis.axis().ticks(0); + } - adjustTicksIfNeeded(chart.yAxis(), chart.height(), MIN_PIXELS_PER_TICK.y); - if (yAxisSplit.length > 1 && chart.rightYAxis) { - adjustTicksIfNeeded(chart.rightYAxis(), chart.height(), MIN_PIXELS_PER_TICK.y); - } + let scale; + if (axis.setting("scale") === "pow") { + scale = d3.scale.pow().exponent(0.5); + } else if (axis.setting("scale") === "log") { + scale = d3.scale.log().base(Math.E); + // axis.axis().tickFormat((d) => scale.tickFormat(4,d3.format(",d"))(d)); } else { - chart.yAxis().ticks(0); - if (chart.rightYAxis) { - chart.rightYAxis().ticks(0); - } + scale = d3.scale.linear(); } - if (settings["graph.y_axis.auto_range"]) { - chart.elasticY(true); + if (axis.setting("auto_range")) { + // elasticY not compatible with log scale + if (axis.setting("scale") !== "log") { + // TODO: right axis? + chart.elasticY(true); + } else { + if (!((yExtent[0] < 0 && yExtent[1] < 0) || (yExtent[0] > 0 && yExtent[1] > 0))) { + throw "Y-axis must not cross 0 when using log scale."; + } + scale.domain(yExtent); + } + axis.scale(scale); } else { - chart.y(d3.scale.linear().domain([settings["graph.y_axis.min"], settings["graph.y_axis.max"]])) + if (axis.setting("scale") === "log" && !( + (axis.setting("min") < 0 && axis.setting("max") < 0) || + (axis.setting("min") > 0 && axis.setting("max") > 0) + )) { + throw "Y-axis must not cross 0 when using log scale."; + } + axis.scale(scale.domain([axis.setting("min"), axis.setting("max")])) } } -function applyChartTooltips(chart, onHoverChange) { +function applyChartTooltips(chart, series, onHoverChange) { + let [{ data: { cols } }] = series; chart.on("renderlet.tooltips", function(chart) { - chart.selectAll(".bar, .dot, .area, .line, g.pie-slice, g.features") + chart.selectAll(".bar, .dot, .area, .line, .bubble, g.pie-slice, g.features") .on("mousemove", function(d, i) { + let data; + if (Array.isArray(d.key)) { // scatter + data = d.key.map((value, index) => ( + { key: getFriendlyName(cols[index]), value: value, col: cols[index] } + )); + } else if (d.data) { // line, area, bar + data = [ + { key: getFriendlyName(cols[0]), value: d.data.key, col: cols[0] }, + { key: getFriendlyName(cols[1]), value: d.data.value, col: cols[1] } + ]; + } + onHoverChange && onHoverChange({ index: determineSeriesIndexFromElement(this), element: this, d: d, - data: d.data + data: data && _.uniq(data, (d) => d.col) }); }) .on("mouseleave", function() { @@ -209,7 +282,7 @@ function applyChartTooltips(chart, onHoverChange) { }); } -function applyChartLineBarSettings(chart, settings, chartType, isLinear, isTimeseries) { +function applyChartLineBarSettings(chart, settings, chartType) { // if the chart supports 'brushing' (brush-based range filter), disable this since it intercepts mouse hovers which means we can't see tooltips if (chart.brushOn) { chart.brushOn(false); @@ -234,11 +307,11 @@ function applyChartLineBarSettings(chart, settings, chartType, isLinear, isTimes if (chart.barPadding) { chart .barPadding(BAR_PADDING_RATIO) - .centerBar(isLinear || isTimeseries); + .centerBar(settings["graph.x_axis.scale"] !== "ordinal"); } } -function lineAndBarOnRender(chart, settings) { +function lineAndBarOnRender(chart, settings, onGoalHover, isSplitAxis) { // once chart has rendered and we can access the SVG, do customizations to axis labels / etc that you can't do through dc.js function removeClipPath() { @@ -294,7 +367,7 @@ function lineAndBarOnRender(chart, settings) { function voronoiHover() { const parent = chart.svg().select("svg > g"); - const dots = chart.svg().selectAll(".dc-tooltip .dot")[0]; + const dots = chart.svg().selectAll(".sub .dc-tooltip .dot")[0]; if (dots.length === 0 || dots.length > VORONOI_MAX_POINTS) { return; @@ -403,6 +476,47 @@ function lineAndBarOnRender(chart, settings) { }); } + function fixStackZIndex() { + // reverse the order of .stack-list and .dc-tooltip-list children so 0 points in stacked + // charts don't appear on top of non-zero points + for (const list of chart.selectAll(".stack-list, .dc-tooltip-list")[0]) { + for (const child of list.childNodes) { + list.insertBefore(list.firstChild, child); + } + } + } + + function cleanupGoal() { + // remove dots + chart.selectAll(".goal .dot").remove(); + + // move to end of the parent node so it's on top + chart.selectAll(".goal").each(function() { this.parentNode.appendChild(this); }); + chart.selectAll(".goal .line").attr({ + "stroke": "rgba(157,160,164, 0.7)", + "stroke-dasharray": "5,5" + }); + + // add the label + let goalLine = chart.selectAll(".goal .line")[0][0]; + if (goalLine) { + let { x, y, width } = goalLine.getBBox(); + const labelOnRight = !isSplitAxis; + chart.selectAll(".goal .stack._0") + .append("text") + .text("Goal") + .attr({ + x: labelOnRight ? x + width : x, + y: y - 5, + "text-anchor": labelOnRight ? "end" : "start", + "font-weight": "bold", + fill: "rgb(157,160,164)", + }) + .on("mouseenter", function() { onGoalHover(this); }) + .on("mouseleave", function() { onGoalHover(null); }) + } + } + // run these first so the rest of the margin computations take it into account hideDisabledLabels(); hideDisabledAxis(); @@ -431,34 +545,141 @@ function lineAndBarOnRender(chart, settings) { hideDisabledAxis(); hideBadAxis(); disableClickFiltering(); + fixStackZIndex(); + cleanupGoal(); }); chart.render(); } +function reduceGroup(group, key) { + return group.reduce( + (acc, d) => (acc == null && d[key] == null) ? null : (acc || 0) + (d[key] || 0), + (acc, d) => (acc == null && d[key] == null) ? null : (acc || 0) - (d[key] || 0), + () => null + ); +} + +function fillMissingValues(datas, xValues, fillValue, getKey = (v) => v) { + try { + return datas.map(rows => { + const fillValues = rows[0].slice(1).map(d => fillValue); + + let map = new Map(); + for (const row of rows) { + map.set(getKey(row[0]), row); + } + let newRows = xValues.map(value => { + const key = getKey(value); + const row = map.get(key); + if (row) { + map.delete(key); + return [value, ...row.slice(1)]; + } else { + return [value, ...fillValues]; + } + }); + if (map.size > 0) { + console.warn("xValues missing!", map, newRows) + } + return newRows; + }); + } catch (e) { + console.warn(e); + return datas; + } +} + export default function lineAreaBar(element, { series, onHoverChange, onRender, chartType, isScalarSeries, settings }) { const colors = settings["graph.colors"]; - const isTimeseries = dimensionIsTimeseries(series[0].data); - const isLinear = false; + const isTimeseries = settings["graph.x_axis.scale"] === "timeseries"; + const isQuantitative = ["linear", "log", "pow"].indexOf(settings["graph.x_axis.scale"]) >= 0; - // validation. we require at least 2 rows for line charting if (series[0].data.cols.length < 2) { - return; + throw "This chart type requires at least 2 columns"; + } + + if (series.length > 20) { + throw "This chart type doesn't support more than 20 series"; } let datas = series.map((s, index) => s.data.rows.map(row => [ - (isTimeseries) ? parseTimestamp(row[0]) : String(row[0]), - ...row.slice(1) + // don't parse as timestamp if we're going to display as a quantitative scale, e.x. years and Unix timestamps + (settings["graph.x_axis._is_timeseries"] && !isQuantitative) ? + parseTimestamp(row[0], s.data.cols[0].unit).toDate() + : settings["graph.x_axis._is_numeric"] ? + row[0] + : + String(row[0]) + , ...row.slice(1) ]) ); + // compute the x-values let xValues = getXValues(datas, chartType); + // compute the domain + let xDomain = d3.extent(xValues); + + let xInterval; + if (isTimeseries) { + // compute the interval + let unit = minTimeseriesUnit(series.map(s => s.data.cols[0].unit)); + xInterval = computeTimeseriesDataInverval(xValues, unit); + } else if (isQuantitative) { + xInterval = computeNumericDataInverval(xValues); + } + + if (settings["line.missing"] === "zero" || settings["line.missing"] === "none") { + if (isTimeseries) { + // replace xValues with + xValues = d3.time[xInterval.interval] + .range(xDomain[0], moment(xDomain[1]).add(1, "ms").toDate(), xInterval.count); + datas = fillMissingValues( + datas, + xValues, + settings["line.missing"] === "zero" ? 0 : null, + (m) => d3.round(m.getTime(), -1) // sometimes rounds up 1ms? + ); + } if (isQuantitative) { + xValues = d3.range(xDomain[0], xDomain[1] + xInterval, xInterval); + datas = fillMissingValues( + datas, + xValues, + settings["line.missing"] === "zero" ? 0 : null, + ); + } else { + datas = fillMissingValues( + datas, + xValues, + settings["line.missing"] === "zero" ? 0 : null + ); + } + } + + if (isScalarSeries) { + xValues = datas.map(data => data[0][0]); + } + let dimension, groups, yAxisSplit; - if (settings["stackable.stacked"] && datas.length > 1) { + const isScatter = chartType === "scatter"; + const isStacked = settings["stackable.stacked"] && datas.length > 1 + + if (isScatter) { + let dataset = crossfilter(); + datas.map(data => dataset.add(data)); + + dimension = dataset.dimension(d => [d[0], d[1]]); + groups = datas.map(data => { + let dim = crossfilter(data).dimension(d => d); + return [ + dim.group().reduceSum((d) => d[2] || 1) + ] + }); + } else if (isStacked) { let dataset = crossfilter(); datas.map((data, i) => dataset.add(data.map(d => ({ @@ -470,11 +691,9 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, dimension = dataset.dimension(d => d[0]); groups = [ datas.map((data, i) => - dimension.group().reduceSum(d => (d[i + 1] || 0)) + reduceGroup(dimension.group(), i + 1) ) ]; - - yAxisSplit = [series.map((s,i) => i)]; } else { let dataset = crossfilter(); datas.map(data => dataset.add(data)); @@ -483,28 +702,25 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, groups = datas.map(data => { let dim = crossfilter(data).dimension(d => d[0]); return data[0].slice(1).map((_, i) => - dim.group().reduceSum(d => (d[i + 1] || 0)) - ) + reduceGroup(dim.group(), i + 1) + ); }); - - let yExtents = groups.map(group => d3.extent(group[0].all(), d => d.value)); - - if (!isScalarSeries && settings["graph.y_axis.auto_split"] !== false) { - yAxisSplit = computeSplit(yExtents); - } else { - yAxisSplit = [series.map((s,i) => i)]; - } } - if (isScalarSeries) { - xValues = datas.map(data => data[0][0]); + let yExtents = groups.map(group => d3.extent(group[0].all(), d => d.value)); + let yExtent = d3.extent([].concat(...yExtents)); + + if (!isScalarSeries && !isScatter && !isStacked && settings["graph.y_axis.auto_split"] !== false) { + yAxisSplit = computeSplit(yExtents); + } else { + yAxisSplit = [series.map((s,i) => i)]; } // HACK: This ensures each group is sorted by the same order as xValues, // otherwise we can end up with line charts with x-axis labels in the correct order // but the points in the wrong order. There may be a more efficient way to do this. // Don't apply to linear or timeseries X-axis since the points are always plotted in order - if (!isTimeseries && !isLinear) { + if (!isTimeseries && !isQuantitative) { let sortMap = new Map() for (const [index, key] of xValues.entries()) { sortMap.set(key, index); @@ -517,24 +733,55 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, } } - let parent; - if (groups.length > 1) { - parent = initializeChart(series[0].card, element, "compositeChart") - } else { - parent = element; - } + let parent = dc.compositeChart(element); + applyChartBoundary(parent, element); + parent.transitionDuration(0); let charts = groups.map((group, index) => { let chart = dc[getDcjsChartType(chartType)](parent); + // disable clicks + chart.onClick = () => {}; + chart .dimension(dimension) .group(group[0]) .transitionDuration(0) - .useRightYAxis(yAxisSplit.length > 1 && yAxisSplit[1].includes(index)) + .useRightYAxis(yAxisSplit.length > 1 && yAxisSplit[1].includes(index)); + + if (isScatter) { + chart + .keyAccessor((d) => d.key[0]) + .valueAccessor((d) => d.key[1]) + + if (chart.radiusValueAccessor) { + const isBubble = datas[index][0].length > 2; + if (isBubble) { + const BUBBLE_SCALE_FACTOR_MAX = 64; + chart + .radiusValueAccessor((d) => d.value) + .r(d3.scale.sqrt() + .domain([0, yExtent[1] * BUBBLE_SCALE_FACTOR_MAX]) + .range([0, 1]) + ); + } else { + chart.radiusValueAccessor((d) => 1) + chart.MIN_RADIUS = 3 + } + chart.minRadiusWithLabel(Infinity); + } + } + + if (chart.defined) { + chart.defined( + settings["line.missing"] === "none" ? + (d) => d.y != null : + (d) => true + ); + } // multiple series - if (groups.length > 1) { + if (groups.length > 1 || isScatter) { // multiple stacks if (group.length > 1) { // compute shades of the assigned color @@ -550,62 +797,90 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, chart.stack(group[i]) } - applyChartLineBarSettings(chart, settings, chartType, isLinear, isTimeseries); + applyChartLineBarSettings(chart, settings, chartType); return chart; }); - let chart; - if (charts.length > 1) { - chart = parent.compose(charts); - - if (!isScalarSeries) { - chart.on("renderlet.grouped-bar", function (chart) { - // HACK: dc.js doesn't support grouped bar charts so we need to manually resize/reposition them - // https://github.com/dc-js/dc.js/issues/558 - let barCharts = chart.selectAll(".sub rect:first-child")[0].map(node => node.parentNode.parentNode.parentNode); - if (barCharts.length > 0) { - let oldBarWidth = parseFloat(barCharts[0].querySelector("rect").getAttribute("width")); - let newBarWidthTotal = oldBarWidth / barCharts.length; - let seriesPadding = - newBarWidthTotal < 4 ? 0 : - newBarWidthTotal < 8 ? 1 : - 2; - let newBarWidth = Math.max(1, newBarWidthTotal - seriesPadding); - - chart.selectAll("g.sub rect").attr("width", newBarWidth); - barCharts.forEach((barChart, index) => { - barChart.setAttribute("transform", "translate(" + ((newBarWidth + seriesPadding) * index) + ", 0)"); - }); - } - }) - } + let onGoalHover = () => {}; + if (settings["graph.show_goal"]) { + const goalData = [[xDomain[0], settings["graph.goal_value"]], [xDomain[1], settings["graph.goal_value"]]]; + const goalDimension = crossfilter(goalData).dimension(d => d[0]); + const goalGroup = goalDimension.group().reduceSum(d => d[1]); + const goalIndex = charts.length; + let goalChart = dc.lineChart(parent) + .dimension(goalDimension) + .group(goalGroup) + .on('renderlet', function (chart) { + // remove "sub" class so the goal is not used in voronoi computation + chart.select(".sub._"+goalIndex) + .classed("sub", false) + .classed("goal", true); + }); + charts.push(goalChart); - // HACK: compositeChart + ordinal X axis shenanigans - if (chartType === "bar") { - chart._rangeBandPadding(BAR_PADDING_RATIO) // https://github.com/dc-js/dc.js/issues/678 - } else { - chart._rangeBandPadding(1) // https://github.com/dc-js/dc.js/issues/662 + onGoalHover = (element) => { + onHoverChange(element && { + element: element, + data: [{ key: "Goal", value: settings["graph.goal"] }] + }); } + } + + let chart = parent.compose(charts); + + if (groups.length > 1 && !isScalarSeries) { + chart.on("renderlet.grouped-bar", function (chart) { + // HACK: dc.js doesn't support grouped bar charts so we need to manually resize/reposition them + // https://github.com/dc-js/dc.js/issues/558 + let barCharts = chart.selectAll(".sub rect:first-child")[0].map(node => node.parentNode.parentNode.parentNode); + if (barCharts.length > 0) { + let oldBarWidth = parseFloat(barCharts[0].querySelector("rect").getAttribute("width")); + let newBarWidthTotal = oldBarWidth / barCharts.length; + let seriesPadding = + newBarWidthTotal < 4 ? 0 : + newBarWidthTotal < 8 ? 1 : + 2; + let newBarWidth = Math.max(1, newBarWidthTotal - seriesPadding); + + chart.selectAll("g.sub rect").attr("width", newBarWidth); + barCharts.forEach((barChart, index) => { + barChart.setAttribute("transform", "translate(" + ((newBarWidth + seriesPadding) * index) + ", 0)"); + }); + } + }) + } + + // HACK: compositeChart + ordinal X axis shenanigans + if (chartType === "bar") { + chart._rangeBandPadding(BAR_PADDING_RATIO) // https://github.com/dc-js/dc.js/issues/678 } else { - chart = charts[0]; - chart.transitionDuration(0) - applyChartBoundary(chart, element); + chart._rangeBandPadding(1) // https://github.com/dc-js/dc.js/issues/662 } // x-axis settings - // TODO: we should support a linear (numeric) x-axis option if (isTimeseries) { - applyChartTimeseriesXAxis(chart, settings, series, xValues); + applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xInterval); + } else if (isQuantitative) { + applyChartQuantitativeXAxis(chart, settings, series, xValues, xDomain, xInterval); } else { applyChartOrdinalXAxis(chart, settings, series, xValues); } // y-axis settings - // TODO: if we are multi-series this could be split axis - applyChartYAxis(chart, settings, series, yAxisSplit); + let [left, right] = yAxisSplit.map(indexes => ({ + series: indexes.map(index => series[index]), + extent: d3.extent([].concat(...indexes.map(index => yExtents[index]))) + })); + if (left && left.series.length > 0) { + applyChartYAxis(chart, settings, left.series, left.extent, "left"); + } + if (right && right.series.length > 0) { + applyChartYAxis(chart, settings, right.series, right.extent, "right"); + } + const isSplitAxis = (right && right.series.length) && (left && left.series.length > 0); - applyChartTooltips(chart, (hovered) => { + applyChartTooltips(chart, series, (hovered) => { if (onHoverChange) { // disable tooltips on lines if (hovered && hovered.element && hovered.element.classList.contains("line")) { @@ -624,7 +899,7 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, chart.render(); // apply any on-rendering functions - lineAndBarOnRender(chart, settings); + lineAndBarOnRender(chart, settings, onGoalHover, isSplitAxis); onRender && onRender({ yAxisSplit }); diff --git a/frontend/src/metabase/visualizations/lib/numeric.js b/frontend/src/metabase/visualizations/lib/numeric.js new file mode 100644 index 0000000000000000000000000000000000000000..bb671b949330e95de50d8b354b3ea793776874af --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/numeric.js @@ -0,0 +1,41 @@ +import { isNumeric } from "metabase/lib/schema_metadata"; + +export function dimensionIsNumeric({ cols, rows }, i = 0) { + return isNumeric(cols[i]) || typeof (rows[0] && rows[0][i]) === "number"; +} + +export function precision(a) { + if (!isFinite(a)) { + return 0; + } + if (!a) { + return 0; + } + var e = 1; + while (Math.round(a / e) !== (a / e)) { + e /= 10; + } + while (Math.round(a / Math.pow(10, e)) === (a / Math.pow(10, e))) { + e *= 10; + } + return e; +} + +export function computeNumericDataInverval(xValues) { + let bestPrecision = Infinity; + for (const value of xValues) { + let p = precision(value) || 1; + if (p < bestPrecision) { + bestPrecision = p; + } + } + return bestPrecision; +} + +// logTickFormat(chart.xAxis()) +export function logTickFormat(axis) { + let superscript = "â°Â¹Â²Â³â´âµâ¶â·â¸â¹"; + let formatPower = (d) => (d + "").split("").map((c) => superscript[c]).join(""); + let formatTick = (d) => 10 + formatPower(Math.round(Math.log(d) / Math.LN10)); + axis.tickFormat(formatTick); +} diff --git a/frontend/src/metabase/visualizations/lib/timeseries.js b/frontend/src/metabase/visualizations/lib/timeseries.js index 7c4ee28a4d7312bb66f06e2e5e85b580c57acaf0..e76c13ae91e8b1989157c01b5674b33d4838d5fd 100644 --- a/frontend/src/metabase/visualizations/lib/timeseries.js +++ b/frontend/src/metabase/visualizations/lib/timeseries.js @@ -1,4 +1,3 @@ -import d3 from "d3"; import moment from "moment"; import { isDate } from "metabase/lib/schema_metadata"; @@ -10,15 +9,15 @@ const TIMESERIES_UNITS = new Set([ "day", "week", "month", - "quarter" - // "year" // https://github.com/metabase/metabase/issues/1992 + "quarter", + "year" // https://github.com/metabase/metabase/issues/1992 ]); // investigate the response from a dataset query and determine if the dimension is a timeseries -export function dimensionIsTimeseries({ cols, rows }) { +export function dimensionIsTimeseries({ cols, rows }, i = 0) { return ( - (isDate(cols[0]) && (cols[0].unit == null || TIMESERIES_UNITS.has(cols[0].unit))) || - moment(rows[0] && rows[0][0], moment.ISO_8601).isValid() + (isDate(cols[i]) && (cols[i].unit == null || TIMESERIES_UNITS.has(cols[i].unit))) || + moment(rows[0] && rows[0][i], moment.ISO_8601).isValid() ); } @@ -96,12 +95,11 @@ export function computeTimeseriesDataInverval(xValues, unit) { return TIMESERIES_INTERVALS[computeTimeseriesDataInvervalIndex(xValues, unit)]; } -export function computeTimeseriesTicksInterval(xValues, unit, chartWidth, minPixels) { +export function computeTimeseriesTicksInterval(xDomain, xInterval, chartWidth, minPixels) { // If the interval that matches the data granularity results in too many ticks reduce the granularity until it doesn't. // TODO: compute this directly instead of iteratively let maxTickCount = Math.round(chartWidth / minPixels); - let xDomain = d3.extent(xValues); - let index = computeTimeseriesDataInvervalIndex(xValues, unit); + let index = TIMESERIES_INTERVALS.indexOf(xInterval); while (index < TIMESERIES_INTERVALS.length - 1) { let interval = TIMESERIES_INTERVALS[index]; let intervalMs = moment(0).add(interval.count, interval.interval).valueOf(); diff --git a/frontend/test/unit/visualizations/lib/numeric.spec.js b/frontend/test/unit/visualizations/lib/numeric.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9c37e860835c733a4271676c7da2ed026bd5f77d --- /dev/null +++ b/frontend/test/unit/visualizations/lib/numeric.spec.js @@ -0,0 +1,47 @@ +import { + precision, + computeNumericDataInverval +} from 'metabase/visualizations/lib/numeric'; + +describe('visualization.lib.numeric', () => { + describe('precision', () => { + const CASES = [ + [0, 0], + [10, 10], + [-10, 10], + [1, 1], + [-1, 1], + [0.1, 0.1], + [-0.1, 0.1], + [0.01, 0.01], + [-0.01, 0.01], + [1.1, 0.1], + [-1.1, 0.1], + [0.5, 0.1], + [0.9, 0.1], + [-0.5, 0.1], + [-0.9, 0.1], + ]; + for (const c of CASES) { + it("precision of " + c[0] + " should be " + c[1], () => { + expect(precision(c[0])).toEqual(c[1]); + }); + } + }); + describe('computeNumericDataInverval', () => { + const CASES = [ + [[0], 1], + [[1], 1], + [[0, 1], 1], + [[0.1, 1], 0.1], + [[0.1, 10], 0.1], + [[10, 1], 1], + [[0, null, 1], 1] + ]; + for (const c of CASES) { + it("precision of " + c[0] + " should be " + c[1], () => { + expect(computeNumericDataInverval(c[0])).toEqual(c[1]); + }); + } + }); +}); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 0c060253bdcb88df04f5b944d2dcdfae28915838..b76f3dce6df45b647f0745309e499fe8f744de01 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -5101,7 +5101,7 @@ "version": "3.5.17" }, "dc": { - "version": "2.0.0-beta.31", + "version": "2.0.0-beta.32", "dependencies": { "crossfilter2": { "version": "1.3.14" @@ -5251,10 +5251,10 @@ } }, "espree": { - "version": "3.1.7", + "version": "3.1.6", "dependencies": { "acorn": { - "version": "3.3.0" + "version": "3.2.0" }, "acorn-jsx": { "version": "3.0.1" @@ -5268,14 +5268,11 @@ "version": "2.0.2" }, "file-entry-cache": { - "version": "1.3.1", + "version": "1.2.4", "dependencies": { "flat-cache": { - "version": "1.2.1", + "version": "1.0.10", "dependencies": { - "circular-json": { - "version": "0.3.1" - }, "del": { "version": "2.2.1", "dependencies": { @@ -5318,12 +5315,15 @@ } }, "rimraf": { - "version": "2.5.4" + "version": "2.5.3" } } }, "graceful-fs": { - "version": "4.1.5" + "version": "4.1.4" + }, + "read-json-sync": { + "version": "1.1.1" }, "write": { "version": "0.2.1" @@ -5356,10 +5356,10 @@ "version": "3.0.2", "dependencies": { "brace-expansion": { - "version": "1.1.6", + "version": "1.1.5", "dependencies": { "balanced-match": { - "version": "0.4.2" + "version": "0.4.1" }, "concat-map": { "version": "0.0.1" @@ -5561,7 +5561,7 @@ } }, "lodash": { - "version": "4.14.1" + "version": "4.13.1" }, "mkdirp": { "version": "0.5.1", @@ -5578,7 +5578,7 @@ "version": "0.1.3" }, "fast-levenshtein": { - "version": "1.1.4" + "version": "1.1.3" }, "prelude-ls": { "version": "1.1.2" diff --git a/package.json b/package.json index 7978a8e6786fa6a9f8b5805d42ca66c448a44f62..8cb58e31cb08da69d0471622a0e0283d20d7fbb1 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "classnames": "^2.1.3", "color": "^0.11.1", "crossfilter": "^1.3.12", - "d3": "^3.5.16", - "dc": "^2.0.0-beta.25", + "d3": "^3.5.17", + "dc": "^2.0.0-beta.32", "diff": "^2.2.1", "fixed-data-table": "^0.6.0", "history": "^3.0.0",