diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js index e14813b6dae0ebe982655fc933f63c88282a212b..5a80f64db661f85bbf867a3f73bf16e1c677bcf2 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js @@ -1,7 +1,13 @@ +/* @flow weak */ + import d3 from "d3"; import { clipPathReference } from "metabase/lib/dom"; +const X_LABEL_MIN_SPACING = 2; // minimum amount of space we want to leave between labels +const X_LABEL_ROTATE_90_THRESHOLD = 40; +const X_LABEL_HIDE_THRESHOLD = 12; + // +-------------------------------------------------------------------------------------------------------------------+ // | ON RENDER FUNCTIONS | // +-------------------------------------------------------------------------------------------------------------------+ @@ -33,11 +39,11 @@ const DOT_OVERLAP_COUNT_LIMIT = 8; const DOT_OVERLAP_RATIO = 0.1; const DOT_OVERLAP_DISTANCE = 8; -function onRenderEnableDots(chart, settings) { +function onRenderEnableDots(chart) { let enableDots; const dots = chart.svg().selectAll(".dc-tooltip .dot")[0]; - if (settings["line.marker_enabled"] != null) { - enableDots = !!settings["line.marker_enabled"]; + if (chart.settings["line.marker_enabled"] != null) { + enableDots = !!chart.settings["line.marker_enabled"]; } else if (dots.length > 500) { // more than 500 dots is almost certainly too dense, don't waste time computing the voronoi map enableDots = false; @@ -207,20 +213,20 @@ function onRenderCleanupGoal(chart, onGoalHover, isSplitAxis) { } } -function onRenderHideDisabledLabels(chart, settings) { - if (!settings["graph.x_axis.labels_enabled"]) { +function onRenderHideDisabledLabels(chart) { + if (!chart.settings["graph.x_axis.labels_enabled"]) { chart.selectAll(".x-axis-label").remove(); } - if (!settings["graph.y_axis.labels_enabled"]) { + if (!chart.settings["graph.y_axis.labels_enabled"]) { chart.selectAll(".y-axis-label").remove(); } } -function onRenderHideDisabledAxis(chart, settings) { - if (!settings["graph.x_axis.axis_enabled"]) { +function onRenderHideDisabledAxis(chart) { + if (!chart.settings["graph.x_axis.axis_enabled"]) { chart.selectAll(".axis.x").remove(); } - if (!settings["graph.y_axis.axis_enabled"]) { + if (!chart.settings["graph.y_axis.axis_enabled"]) { chart.selectAll(".axis.y, .axis.yr").remove(); } } @@ -252,8 +258,8 @@ function onRenderSetClassName(chart, isStacked) { chart.svg().classed("stacked", isStacked); } -function getXAxisRotation(settings) { - let match = (settings["graph.x_axis.labels_style"] || "").match( +function getXAxisRotation(chart) { + let match = String(chart.settings["graph.x_axis.axis_enabled"] || "").match( /^rotate-(\d+)$/, ); if (match) { @@ -263,8 +269,8 @@ function getXAxisRotation(settings) { } } -function onRenderRotateAxis(chart, settings) { - let degrees = getXAxisRotation(settings); +function onRenderRotateAxis(chart) { + let degrees = getXAxisRotation(chart); if (degrees !== 0) { chart.selectAll("g.x text").attr("transform", function() { const { width, height } = this.getBBox(); @@ -275,20 +281,20 @@ function onRenderRotateAxis(chart, settings) { } // the various steps that get called -function onRender(chart, settings, onGoalHover, isSplitAxis, isStacked) { +function onRender(chart, onGoalHover, isSplitAxis, isStacked) { onRenderRemoveClipPath(chart); onRenderMoveContentToTop(chart); onRenderSetDotStyle(chart); - onRenderEnableDots(chart, settings); + onRenderEnableDots(chart); onRenderVoronoiHover(chart); onRenderCleanupGoal(chart, onGoalHover, isSplitAxis); // do this before hiding x-axis - onRenderHideDisabledLabels(chart, settings); - onRenderHideDisabledAxis(chart, settings); + onRenderHideDisabledLabels(chart); + onRenderHideDisabledAxis(chart); onRenderHideBadAxis(chart); onRenderDisableClickFiltering(chart); onRenderFixStackZIndex(chart); onRenderSetClassName(chart, isStacked); - onRenderRotateAxis(chart, settings); + onRenderRotateAxis(chart); } // +-------------------------------------------------------------------------------------------------------------------+ @@ -296,9 +302,9 @@ function onRender(chart, settings, onGoalHover, isSplitAxis, isStacked) { // +-------------------------------------------------------------------------------------------------------------------+ // run these first so the rest of the margin computations take it into account -function beforeRenderHideDisabledAxesAndLabels(chart, settings) { - onRenderHideDisabledLabels(chart, settings); - onRenderHideDisabledAxis(chart, settings); +function beforeRenderHideDisabledAxesAndLabels(chart) { + onRenderHideDisabledLabels(chart); + onRenderHideDisabledAxis(chart); onRenderHideBadAxis(chart); } @@ -344,9 +350,8 @@ function computeMinHorizontalMargins(chart) { return min; } -function computeXAxisMargin(chart, settings) { - const rotation = getXAxisRotation(settings); - +function computeXAxisMargin(chart) { + const rotation = getXAxisRotation(chart); let maxWidth = 0; let maxHeight = 0; chart.selectAll("g.x text").each(function() { @@ -354,14 +359,67 @@ function computeXAxisMargin(chart, settings) { maxWidth = Math.max(maxWidth, width); maxHeight = Math.max(maxHeight, height); }); - const rotatedMaxHeight = Math.sin(Math.radians(rotation + 180)) * maxWidth; - return rotatedMaxHeight - maxHeight; // subtract the existing height + const rotatedMaxHeight = + Math.sin((rotation + 180) * (Math.PI / 180)) * maxWidth; + return Math.max(0, rotatedMaxHeight - maxHeight); // subtract the existing height +} + +function checkLabelOverlap(chart) { + const rects = []; + for (const elem of chart.selectAll("g.x text")[0]) { + rects.push(elem.getBoundingClientRect()); + if ( + rects.length > 1 && + rects[rects.length - 2].right + X_LABEL_MIN_SPACING > + rects[rects.length - 1].left + ) { + return true; + } + } + return false; +} + +function computeXAxisSpacing(chart) { + const rects = []; + let minXAxisSpacing = Infinity; + for (const elem of chart.selectAll("g.x text")[0]) { + rects.push(elem.getBoundingClientRect()); + if (rects.length > 1) { + const left = rects[rects.length - 2], + right = rects[rects.length - 1]; + const xAxisSpacing = + right.left + right.width / 2 - (left.left + left.width / 2); + minXAxisSpacing = Math.min(minXAxisSpacing, xAxisSpacing); + } + } + return minXAxisSpacing; +} + +function beforeRenderComputeXAxisLabelType(chart) { + // treat graph.x_axis.axis_enabled === true as "auto" + if (chart.settings["graph.x_axis.axis_enabled"] === true) { + const overlaps = checkLabelOverlap(chart); + if (overlaps) { + if (chart.isOrdinal()) { + const spacing = computeXAxisSpacing(chart); + if (spacing < X_LABEL_HIDE_THRESHOLD) { + chart.settings["graph.x_axis.axis_enabled"] = false; + } else if (spacing < X_LABEL_ROTATE_90_THRESHOLD) { + chart.settings["graph.x_axis.axis_enabled"] = "rotate-90"; + } else { + chart.settings["graph.x_axis.axis_enabled"] = "rotate-45"; + } + } else { + chart.settings["graph.x_axis.axis_enabled"] = false; + } + } + } } -function beforeRenderFixMargins(chart, settings) { +function beforeRenderFixMargins(chart) { // run before adjusting margins const mins = computeMinHorizontalMargins(chart); - const xAxisMargin = computeXAxisMargin(chart, settings); + const xAxisMargin = computeXAxisMargin(chart); // adjust the margins to fit the X and Y axis tick and label sizes, if enabled adjustMargin( @@ -371,7 +429,6 @@ function beforeRenderFixMargins(chart, settings) { X_AXIS_PADDING + xAxisMargin, ".axis.x", ".x-axis-label", - settings["graph.x_axis.labels_enabled"], ); adjustMargin( chart, @@ -380,7 +437,6 @@ function beforeRenderFixMargins(chart, settings) { Y_AXIS_PADDING, ".axis.y", ".y-axis-label.y-label", - settings["graph.y_axis.labels_enabled"], ); adjustMargin( chart, @@ -389,7 +445,6 @@ function beforeRenderFixMargins(chart, settings) { Y_AXIS_PADDING, ".axis.yr", ".y-axis-label.yr-label", - settings["graph.y_axis.labels_enabled"], ); // set margins to the max of the various mins @@ -408,9 +463,10 @@ function beforeRenderFixMargins(chart, settings) { } // collection of function calls that get made *before* we tell the Chart to render -function beforeRender(chart, settings) { - beforeRenderHideDisabledAxesAndLabels(chart, settings); - beforeRenderFixMargins(chart, settings); +function beforeRender(chart) { + beforeRenderComputeXAxisLabelType(chart); + beforeRenderHideDisabledAxesAndLabels(chart); + beforeRenderFixMargins(chart); } // +-------------------------------------------------------------------------------------------------------------------+ @@ -420,14 +476,13 @@ function beforeRender(chart, settings) { /// once chart has rendered and we can access the SVG, do customizations to axis labels / etc that you can't do through dc.js export default function lineAndBarOnRender( chart, - settings, onGoalHover, isSplitAxis, isStacked, ) { - beforeRender(chart, settings); + beforeRender(chart); chart.on("renderlet.on-render", () => - onRender(chart, settings, onGoalHover, isSplitAxis, isStacked), + onRender(chart, onGoalHover, isSplitAxis, isStacked), ); chart.render(); } diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index e3ce80790d7afacf7e54a35cbeecbadb6e0dc735..a75b94e5f6bb09a87618f410faf17df6022162f3 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -502,31 +502,19 @@ function addGoalChartAndGetOnGoalHover( }; } -function applyXAxisSettings({ settings, series }, xAxisProps, parent) { - if (isTimeseries(settings)) - applyChartTimeseriesXAxis(parent, settings, series, xAxisProps); - else if (isQuantitative(settings)) - applyChartQuantitativeXAxis(parent, settings, series, xAxisProps); - else applyChartOrdinalXAxis(parent, settings, series, xAxisProps); +function applyXAxisSettings(parent, series, xAxisProps) { + if (isTimeseries(parent.settings)) + applyChartTimeseriesXAxis(parent, series, xAxisProps); + else if (isQuantitative(parent.settings)) + applyChartQuantitativeXAxis(parent, series, xAxisProps); + else applyChartOrdinalXAxis(parent, series, xAxisProps); } -function applyYAxisSettings({ settings }, { yLeftSplit, yRightSplit }, parent) { +function applyYAxisSettings(parent, { yLeftSplit, yRightSplit }) { if (yLeftSplit && yLeftSplit.series.length > 0) - applyChartYAxis( - parent, - settings, - yLeftSplit.series, - yLeftSplit.extent, - "left", - ); + applyChartYAxis(parent, yLeftSplit.series, yLeftSplit.extent, "left"); if (yRightSplit && yRightSplit.series.length > 0) - applyChartYAxis( - parent, - settings, - yRightSplit.series, - yRightSplit.extent, - "right", - ); + applyChartYAxis(parent, yRightSplit.series, yRightSplit.extent, "right"); } // TODO - better name @@ -628,6 +616,9 @@ export default function lineAreaBar(element: Element, props: LineAreaBarProps) { const parent = dc.compositeChart(element); initChart(parent, element); + // copy settings so we can mutate based on runtime conditions + parent.settings = { ...settings }; + const brushChangeFunctions = makeBrushChangeFunctions(props); const charts = getCharts( @@ -654,12 +645,12 @@ export default function lineAreaBar(element: Element, props: LineAreaBarProps) { // HACK: compositeChart + ordinal X axis shenanigans. See https://github.com/dc-js/dc.js/issues/678 and https://github.com/dc-js/dc.js/issues/662 parent._rangeBandPadding(chartType === "bar" ? BAR_PADDING_RATIO : 1); // - applyXAxisSettings(props, xAxisProps, parent); + applyXAxisSettings(parent, props.series, xAxisProps); // override tick format for bars. ticks are aligned with beginning of bar, so just show the start value if (isHistogramBar(props)) parent.xAxis().tickFormat(d => formatNumber(d)); - applyYAxisSettings(props, yAxisProps, parent); + applyYAxisSettings(parent, yAxisProps); setupTooltips(props, datas, parent, brushChangeFunctions); @@ -668,14 +659,13 @@ export default function lineAreaBar(element: Element, props: LineAreaBarProps) { // apply any on-rendering functions (this code lives in `LineAreaBarPostRenderer`) lineAndBarOnRender( parent, - settings, onGoalHover, yAxisProps.isSplit, - isStacked(settings, datas), + isStacked(parent.settings, datas), ); // only ordinal axis can display "null" values - if (isOrdinal(settings)) delete warnings[NULL_DIMENSION_WARNING]; + if (isOrdinal(parent.settings)) delete warnings[NULL_DIMENSION_WARNING]; if (onRender) onRender({ diff --git a/frontend/src/metabase/visualizations/lib/apply_axis.js b/frontend/src/metabase/visualizations/lib/apply_axis.js index 8b3e190dbb752cccf21b26a42cfcc8673aab6976..8581d848682df22c27ce09df17322727f5a757f2 100644 --- a/frontend/src/metabase/visualizations/lib/apply_axis.js +++ b/frontend/src/metabase/visualizations/lib/apply_axis.js @@ -77,7 +77,6 @@ function adjustXAxisTicksIfNeeded(axis, chartWidthPixels, xValues) { export function applyChartTimeseriesXAxis( chart, - settings, series, { xValues, xDomain, xInterval }, ) { @@ -98,14 +97,17 @@ export function applyChartTimeseriesXAxis( let dataInterval = xInterval; let tickInterval = dataInterval; - if (settings["graph.x_axis.labels_enabled"]) { + if (chart.settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel( - settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), + chart.settings["graph.x_axis.title_text"] || + getFriendlyName(dimensionColumn), X_LABEL_PADDING, ); } - if (settings["graph.x_axis.axis_enabled"]) { - chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]); + if (chart.settings["graph.x_axis.axis_enabled"]) { + chart.renderVerticalGridLines( + chart.settings["graph.x_axis.gridLine_enabled"], + ); if (dimensionColumn.unit == null) { dimensionColumn = { ...dimensionColumn, unit: dataInterval.interval }; @@ -138,7 +140,7 @@ export function applyChartTimeseriesXAxis( return formatValue(timestampFixed, { column: dimensionColumn, type: "axis", - compact: settings["graph.x_axis.labels_style"] === "compact", + compact: chart.settings["graph.x_axis.axis_enabled"] === "compact", }); }); @@ -176,7 +178,6 @@ export function applyChartTimeseriesXAxis( export function applyChartQuantitativeXAxis( chart, - settings, series, { xValues, xDomain, xInterval }, ) { @@ -188,21 +189,24 @@ export function applyChartQuantitativeXAxis( ); const dimensionColumn = firstSeries.data.cols[0]; - if (settings["graph.x_axis.labels_enabled"]) { + if (chart.settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel( - settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), + chart.settings["graph.x_axis.title_text"] || + getFriendlyName(dimensionColumn), X_LABEL_PADDING, ); } - if (settings["graph.x_axis.axis_enabled"]) { - chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]); + if (chart.settings["graph.x_axis.axis_enabled"]) { + chart.renderVerticalGridLines( + chart.settings["graph.x_axis.gridLine_enabled"], + ); adjustXAxisTicksIfNeeded(chart.xAxis(), chart.width(), xValues); chart.xAxis().tickFormat(d => formatValue(d, { column: dimensionColumn, type: "axis", - compact: settings["graph.x_axis.labels_style"] === "compact", + compact: chart.settings["graph.x_axis.axis_enabled"] === "compact", }), ); } else { @@ -211,9 +215,9 @@ export function applyChartQuantitativeXAxis( } let scale; - if (settings["graph.x_axis.scale"] === "pow") { + if (chart.settings["graph.x_axis.scale"] === "pow") { scale = d3.scale.pow().exponent(0.5); - } else if (settings["graph.x_axis.scale"] === "log") { + } else if (chart.settings["graph.x_axis.scale"] === "log") { scale = d3.scale.log().base(Math.E); if ( !( @@ -233,7 +237,7 @@ export function applyChartQuantitativeXAxis( chart.x(scale.domain(xDomain)).xUnits(dc.units.fp.precision(xInterval)); } -export function applyChartOrdinalXAxis(chart, settings, series, { xValues }) { +export function applyChartOrdinalXAxis(chart, series, { xValues }) { // find the first nonempty single series // $FlowFixMe const firstSeries: SingleSeries = _.find( @@ -243,32 +247,25 @@ export function applyChartOrdinalXAxis(chart, settings, series, { xValues }) { const dimensionColumn = firstSeries.data.cols[0]; - if (settings["graph.x_axis.labels_enabled"]) { + if (chart.settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel( - settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), + chart.settings["graph.x_axis.title_text"] || + getFriendlyName(dimensionColumn), X_LABEL_PADDING, ); } - if (settings["graph.x_axis.axis_enabled"]) { - chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]); + if (chart.settings["graph.x_axis.axis_enabled"]) { + chart.renderVerticalGridLines( + chart.settings["graph.x_axis.gridLine_enabled"], + ); chart.xAxis().ticks(xValues.length); adjustXAxisTicksIfNeeded(chart.xAxis(), chart.width(), xValues); - if (settings["graph.x_axis.labels_style"] == null) { - // unfortunately with ordinal axis you can't rely on xAxis.ticks(num) to control the display of labels - // so instead if we want to display fewer ticks than our full set we need to calculate visibleTicks() - const numTicks = getNumTicks(chart.xAxis()); - if (numTicks < xValues.length) { - let keyInterval = Math.round(xValues.length / numTicks); - let visibleKeys = xValues.filter((v, i) => i % keyInterval === 0); - chart.xAxis().tickValues(visibleKeys); - } - } chart.xAxis().tickFormat(d => formatValue(d, { column: dimensionColumn, type: "axis", - compact: settings["graph.x_axis.labels_style"] === "compact", + compact: chart.settings["graph.x_axis.labels_enabled"] === "compact", }), ); } else { @@ -279,21 +276,21 @@ export function applyChartOrdinalXAxis(chart, settings, series, { xValues }) { chart.x(d3.scale.ordinal().domain(xValues)).xUnits(dc.units.ordinal); } -export function applyChartYAxis(chart, settings, series, yExtent, axisName) { +export function applyChartYAxis(chart, series, yExtent, axisName) { let axis; if (axisName !== "right") { axis = { scale: (...args) => chart.y(...args), axis: (...args) => chart.yAxis(...args), label: (...args) => chart.yAxisLabel(...args), - setting: name => settings["graph.y_axis." + name], + setting: name => chart.settings["graph.y_axis." + name], }; } else { 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 + setting: name => chart.settings["graph.y_axis." + name], // TODO: right axis settings }; } @@ -314,7 +311,7 @@ export function applyChartYAxis(chart, settings, series, yExtent, axisName) { // special case for normalized stacked charts // for normalized stacked charts the y-axis is a percentage number. In Javascript, 0.07 * 100.0 = 7.000000000000001 (try it) so we // round that number to get something nice like "7". Then we append "%" to get a nice tick like "7%" - if (settings["stackable.stack_type"] === "normalized") { + if (chart.settings["stackable.stack_type"] === "normalized") { axis.axis().tickFormat(value => Math.round(value * 100) + "%"); } chart.renderHorizontalGridLines(true); diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js index 798af4810fcd0421e5ac0dd502edb8b3059cae4f..d221ac18610e8683d6aa9c36b7e4e35d7493c4f7 100644 --- a/frontend/src/metabase/visualizations/lib/settings/graph.js +++ b/frontend/src/metabase/visualizations/lib/settings/graph.js @@ -315,7 +315,16 @@ export const GRAPH_AXIS_SETTINGS = { "graph.x_axis.axis_enabled": { section: "Axes", title: t`Show x-axis line and marks`, - widget: "toggle", + widget: "select", + props: { + options: [ + { name: t`Disabled`, value: false }, + { name: t`Enabled`, value: true }, + { name: t`Compact`, value: "compact" }, + { name: t`45°`, value: "rotate-45" }, + { name: t`90°`, value: "rotate-90" }, + ], + }, default: true, }, "graph.y_axis.axis_enabled": { @@ -381,20 +390,6 @@ export const GRAPH_AXIS_SETTINGS = { widget: "toggle", default: true, }, - "graph.x_axis.labels_style": { - section: "Labels", - title: t`X-axis label style`, - widget: "select", - props: { - options: [ - { name: t`Default`, value: null }, - { name: t`Compact`, value: "compact" }, - { name: t`45°`, value: "rotate-45" }, - { name: t`90°`, value: "rotate-90" }, - ], - }, - default: null - }, "graph.x_axis.title_text": { section: "Labels", title: t`X-axis label`,