diff --git a/frontend/src/metabase/css/query_builder.css b/frontend/src/metabase/css/query_builder.css index 551ded5e24ebb8fd4b87dd02d7ac54a2d18de8a3..1f95889a027cc1e07c9d0bbc2333aff40aae4678 100644 --- a/frontend/src/metabase/css/query_builder.css +++ b/frontend/src/metabase/css/query_builder.css @@ -687,3 +687,18 @@ .ParameterValuePickerNoPopover input::-webkit-input-placeholder { color: var(--color-text-medium); } + +/* TEMP - spot for value labels */ +text.value-label-outline { + font-weight: 900; + stroke-width: 4px; + stroke: var(--color-text-white); +} + +text.value-label { + font-weight: 900; +} + +.Dashboard--night text.value-label-outline { + stroke: var(--night-mode-card); +} diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js index 8e02745e56746e2b1219304bcfa4cbd5d3d6ce3c..304e2f1a1f0702dccd4e8bd1dc042467f19a804f 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js @@ -6,6 +6,7 @@ import _ from "underscore"; import { color } from "metabase/lib/colors"; import { clipPathReference } from "metabase/lib/dom"; import { adjustYAxisTicksIfNeeded } from "./apply_axis"; +import { isHistogramBar } from "./renderer_utils"; const X_LABEL_MIN_SPACING = 2; // minimum space we want to leave between labels const X_LABEL_ROTATE_90_THRESHOLD = 24; // tick width breakpoint for switching from 45° to 90° @@ -245,6 +246,128 @@ function onRenderVoronoiHover(chart) { .order(); } +function onRenderValueLabels(chart, formatYValue, [data]) { + const hasDuplicateX = new Set(data.map(([x]) => x)).size < data.length; + if ( + !chart.settings["graph.show_values"] || // setting is off + chart.settings["stackable.stack_type"] === "normalized" || // no normalized + chart.series.length > 1 || // no multiseries + hasDuplicateX // need unique x values + ) { + return; + } + const showAll = chart.settings["graph.label_value_frequency"] === "all"; + const { display } = chart.settings.series(chart.series[0]); + + // Update `data` to use named x/y and include `showLabelBelow`. + // We need to do that before data is filtered to show every nth value. + data = data.map(([x, y], i) => { + const isLocalMin = + // first point or prior is greater than y + (i === 0 || data[i - 1][1] > y) && + // last point point or next is greater than y + (i === data.length - 1 || data[i + 1][1] > y); + const showLabelBelow = isLocalMin && display === "line"; + return { x, y, showLabelBelow }; + }); + + // use the chart body so things line up properly + const parent = chart.svg().select(".chart-body"); + + const xScale = chart.x(); + const yScale = chart.y(); + + // Ordinal bar charts and histograms need extra logic to center the label. + let xShift = 0; + if (xScale.rangeBand) { + xShift += xScale.rangeBand() / 2; + } + if (isHistogramBar({ settings: chart.settings, chartType: display })) { + // this has to match the logic in `doHistogramBarStuff` + const [x1, x2] = chart + .svg() + .selectAll("rect") + .flat() + .map(r => parseFloat(r.getAttribute("x"))); + const barWidth = x2 - x1; + xShift += barWidth / 2; + } + + const addLabels = data => { + // Safari had an issue with rendering paint-order: stroke. To work around + // that, we create two text labels: one for the the black text and another + // for the white outline behind it. + const labelGroups = parent + .append("svg:g") + .classed("value-labels", true) + .selectAll("g") + .data(data) + .enter() + .append("g") + .attr("transform", ({ x, y, showLabelBelow }) => { + const xPos = xShift + xScale(x); + let yPos = yScale(y) + (showLabelBelow ? 14 : -10); + // if the yPos is below the x axis, move it to be above the data point + const [yMax] = yScale.range(); + if (yPos > yMax) { + yPos = yScale(y) - 10; + } + return `translate(${xPos}, ${yPos})`; + }); + + ["value-label-outline", "value-label"].forEach(klass => + labelGroups + .append("text") + .attr("class", klass) + .attr("text-anchor", "middle") + .attr("alignment-baseline", "middle") + .text(({ y }) => formatYValue(y, { compact: true })), + ); + }; + + let nth; + if (showAll) { + // show all + nth = 1; + } else { + // auto fit + // Render a sample of rows to estimate average label size. + // We use that estimate to compute the label interval. + const LABEL_PADDING = 6; + const MAX_SAMPLE_SIZE = 30; + const sampleSize = Math.min(data.length, MAX_SAMPLE_SIZE); + // $FlowFixMe + addLabels(_.sample(data, sampleSize)); + const totalWidth = chart + .svg() + .selectAll(".value-label-outline") + .flat() + .reduce((sum, label) => sum + label.getBoundingClientRect().width, 0); + const labelWidth = totalWidth / sampleSize + LABEL_PADDING; + + const { width: chartWidth } = chart + .svg() + .select(".axis.x") + .node() + .getBoundingClientRect(); + + chart + .svg() + .select(".value-labels") + .remove(); + nth = Math.ceil((labelWidth * data.length) / chartWidth); + } + + addLabels(data.filter((d, i) => i % nth === 0)); + + moveToTop( + chart + .svg() + .select(".value-labels") + .node().parentNode, + ); +} + function onRenderCleanupGoalAndTrend(chart, onGoalHover, isSplitAxis) { // remove dots chart.selectAll(".goal .dot, .trend .dot").remove(); @@ -378,7 +501,10 @@ function onRenderAddExtraClickHandlers(chart) { } // the various steps that get called -function onRender(chart, onGoalHover, isSplitAxis, isStacked) { +function onRender( + chart, + { onGoalHover, isSplitAxis, isStacked, formatYValue, datas }, +) { onRenderRemoveClipPath(chart); onRenderMoveContentToTop(chart); onRenderReorderCharts(chart); @@ -387,6 +513,7 @@ function onRender(chart, onGoalHover, isSplitAxis, isStacked) { onRenderEnableDots(chart); onRenderVoronoiHover(chart); onRenderCleanupGoalAndTrend(chart, onGoalHover, isSplitAxis); // do this before hiding x-axis + onRenderValueLabels(chart, formatYValue, datas); onRenderHideDisabledLabels(chart); onRenderHideDisabledAxis(chart); onRenderHideBadAxis(chart); @@ -610,15 +737,8 @@ function beforeRender(chart) { // +-------------------------------------------------------------------------------------------------------------------+ /// 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, - onGoalHover, - isSplitAxis, - isStacked, -) { +export default function lineAndBarOnRender(chart, args) { beforeRender(chart); - chart.on("renderlet.on-render", () => - onRender(chart, onGoalHover, isSplitAxis, isStacked), - ); + chart.on("renderlet.on-render", () => onRender(chart, args)); chart.render(); } diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index 9faf2198840c9d0dd58df51f6bf7e72314364c36..3455ea17a8eee9c36dc6c67bd53771e604e9b6e5 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -30,6 +30,7 @@ import { applyChartQuantitativeXAxis, applyChartOrdinalXAxis, applyChartYAxis, + getYValueFormatter, } from "./apply_axis"; import { setupTooltips } from "./apply_tooltips"; @@ -886,12 +887,13 @@ export default function lineAreaBar( parent.render(); // apply any on-rendering functions (this code lives in `LineAreaBarPostRenderer`) - lineAndBarOnRender( - parent, + lineAndBarOnRender(parent, { onGoalHover, - yAxisProps.isSplit, - isStacked(parent.settings, datas), - ); + isSplitAxis: yAxisProps.isSplit, + isStacked: isStacked(parent.settings, datas), + formatYValue: getYValueFormatter(parent, series, yAxisProps.yExtent), + datas, + }); // only ordinal axis can display "null" values if (isOrdinal(parent.settings)) { diff --git a/frontend/src/metabase/visualizations/lib/apply_axis.js b/frontend/src/metabase/visualizations/lib/apply_axis.js index 3b57de676ab73798572d0d295d2508cfb5ddff5e..4bf688c40165111ebabb81baf507d4af2a1eb4ac 100644 --- a/frontend/src/metabase/visualizations/lib/apply_axis.js +++ b/frontend/src/metabase/visualizations/lib/apply_axis.js @@ -356,18 +356,7 @@ export function applyChartYAxis(chart, series, yExtent, axisName) { } if (axis.setting("axis_enabled")) { - // 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 (chart.settings["stackable.stack_type"] === "normalized") { - axis.axis().tickFormat(value => Math.round(value * 100) + "%"); - } else { - const metricColumn = series[0].data.cols[1]; - axis.axis().tickFormat(value => { - value = maybeRoundValueToZero(value, yExtent); - return formatValue(value, chart.settings.column(metricColumn)); - }); - } + axis.axis().tickFormat(getYValueFormatter(chart, series, yExtent)); chart.renderHorizontalGridLines(true); adjustYAxisTicksIfNeeded(axis.axis(), chart.height()); } else { @@ -427,3 +416,18 @@ export function applyChartYAxis(chart, series, yExtent, axisName) { axis.scale(scale.domain([min, max])); } } + +export function getYValueFormatter(chart, series, yExtent) { + // 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 (chart.settings["stackable.stack_type"] === "normalized") { + return value => Math.round(value * 100) + "%"; + } + const metricColumn = series[0].data.cols[1]; + const columnSettings = chart.settings.column(metricColumn); + return (value, options) => { + const roundedValue = maybeRoundValueToZero(value, yExtent); + return formatValue(roundedValue, { ...columnSettings, ...options }); + }; +} diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js index b716c9f33bb4f9a8ced1f58f4d50e048d6d6442a..6656b5ca00d164c59935753ebe984de88e5e59a4 100644 --- a/frontend/src/metabase/visualizations/lib/settings/graph.js +++ b/frontend/src/metabase/visualizations/lib/settings/graph.js @@ -278,7 +278,7 @@ export const STACKABLE_SETTINGS = { export const GRAPH_GOAL_SETTINGS = { "graph.show_goal": { section: t`Display`, - title: t`Show goal`, + title: t`Goal line`, widget: "toggle", default: false, }, @@ -300,7 +300,7 @@ export const GRAPH_GOAL_SETTINGS = { }, "graph.show_trendline": { section: t`Display`, - title: t`Show trend line`, + title: t`Trend line`, widget: "toggle", default: false, getHidden: (series, vizSettings) => { @@ -311,6 +311,38 @@ export const GRAPH_GOAL_SETTINGS = { }, }; +// with more than this many rows, don't display values on top of bars by default +const AUTO_SHOW_VALUES_MAX_ROWS = 25; + +export const GRAPH_DISPLAY_VALUES_SETTINGS = { + "graph.show_values": { + section: t`Display`, + title: t`Show values on data points`, + widget: "toggle", + getHidden: (series, vizSettings) => + series.length > 1 || vizSettings["stackable.stack_type"] === "normalized", + getDefault: ([{ card, data }]) => + card.display === "bar" && data.rows.length < AUTO_SHOW_VALUES_MAX_ROWS, + }, + "graph.label_value_frequency": { + section: t`Display`, + title: t`Values to show`, + widget: "radio", + getHidden: (series, vizSettings) => + series.length > 1 || + vizSettings["graph.show_values"] !== true || + vizSettings["stackable.stack_type"] === "normalized", + props: { + options: [ + { name: t`As many as can fit nicely`, value: "fit" }, + { name: t`All`, value: "all" }, + ], + }, + default: "fit", + readDependencies: ["graph.show_values"], + }, +}; + export const GRAPH_COLORS_SETTINGS = { // DEPRECATED: replaced with "color" series setting "graph.colors": {}, diff --git a/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx b/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx index e66b4638df036ae6325fdd74d2510c82ff719232..f30ce548c4bb28bc9487005eaadf4709fe6558db 100644 --- a/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx +++ b/frontend/src/metabase/visualizations/visualizations/AreaChart.jsx @@ -12,6 +12,7 @@ import { GRAPH_GOAL_SETTINGS, GRAPH_COLORS_SETTINGS, GRAPH_AXIS_SETTINGS, + GRAPH_DISPLAY_VALUES_SETTINGS, } from "../lib/settings/graph"; export default class AreaChart extends LineAreaBarChart { @@ -27,6 +28,7 @@ export default class AreaChart extends LineAreaBarChart { ...GRAPH_GOAL_SETTINGS, ...GRAPH_COLORS_SETTINGS, ...GRAPH_AXIS_SETTINGS, + ...GRAPH_DISPLAY_VALUES_SETTINGS, }; static renderer = areaRenderer; diff --git a/frontend/src/metabase/visualizations/visualizations/BarChart.jsx b/frontend/src/metabase/visualizations/visualizations/BarChart.jsx index 270422a76076d28886f6de5b656285749f8bcd23..f48d3b13f37af5dff9842f21a2ebe7213f35acb0 100644 --- a/frontend/src/metabase/visualizations/visualizations/BarChart.jsx +++ b/frontend/src/metabase/visualizations/visualizations/BarChart.jsx @@ -11,6 +11,7 @@ import { GRAPH_GOAL_SETTINGS, GRAPH_COLORS_SETTINGS, GRAPH_AXIS_SETTINGS, + GRAPH_DISPLAY_VALUES_SETTINGS, } from "../lib/settings/graph"; export default class BarChart extends LineAreaBarChart { @@ -25,6 +26,7 @@ export default class BarChart extends LineAreaBarChart { ...GRAPH_GOAL_SETTINGS, ...GRAPH_COLORS_SETTINGS, ...GRAPH_AXIS_SETTINGS, + ...GRAPH_DISPLAY_VALUES_SETTINGS, }; static renderer = barRenderer; diff --git a/frontend/src/metabase/visualizations/visualizations/LineChart.jsx b/frontend/src/metabase/visualizations/visualizations/LineChart.jsx index b99ae590a044bd19095abfb1e9cfb37f58b0db14..d536a8843608b1d44f06821a6a8946b895cca7f2 100644 --- a/frontend/src/metabase/visualizations/visualizations/LineChart.jsx +++ b/frontend/src/metabase/visualizations/visualizations/LineChart.jsx @@ -10,6 +10,7 @@ import { GRAPH_GOAL_SETTINGS, GRAPH_COLORS_SETTINGS, GRAPH_AXIS_SETTINGS, + GRAPH_DISPLAY_VALUES_SETTINGS, } from "../lib/settings/graph"; export default class LineChart extends LineAreaBarChart { @@ -24,6 +25,7 @@ export default class LineChart extends LineAreaBarChart { ...GRAPH_GOAL_SETTINGS, ...GRAPH_COLORS_SETTINGS, ...GRAPH_AXIS_SETTINGS, + ...GRAPH_DISPLAY_VALUES_SETTINGS, }; static renderer = lineRenderer;