Skip to content
Snippets Groups Projects
Commit 24f8550a authored by Cam Saul's avatar Cam Saul Committed by GitHub
Browse files

Merge pull request #6141 from metabase/chart-lib-cleanup

Chart lib cleanup
parents c9fb3407 c0e84630
Branches
Tags
No related merge requests found
import d3 from "d3";
import { clipPathReference } from "metabase/lib/dom";
// +-------------------------------------------------------------------------------------------------------------------+
// | ON RENDER FUNCTIONS |
// +-------------------------------------------------------------------------------------------------------------------+
// The following functions are applied once the chart is rendered.
function onRenderRemoveClipPath(chart) {
for (let elem of chart.selectAll(".sub, .chart-body")[0]) {
// prevents dots from being clipped:
elem.removeAttribute("clip-path");
}
}
function onRenderMoveContentToTop(chart) {
for (let elem of chart.selectAll(".sub, .chart-body")[0]) {
// move chart content on top of axis (z-index doesn't work on SVG):
elem.parentNode.appendChild(elem);
}
}
function onRenderSetDotStyle(chart) {
for (let elem of chart.svg().selectAll('.dc-tooltip circle.dot')[0]) {
// set the color of the dots to the fill color so we can use currentColor in CSS rules:
elem.style.color = elem.getAttribute("fill");
}
}
const DOT_OVERLAP_COUNT_LIMIT = 8;
const DOT_OVERLAP_RATIO = 0.10;
const DOT_OVERLAP_DISTANCE = 8;
function onRenderEnableDots(chart, settings) {
let enableDots;
const dots = chart.svg().selectAll(".dc-tooltip .dot")[0];
if (settings["line.marker_enabled"] != null) {
enableDots = !!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;
} else {
const vertices = dots.map((e, index) => {
let rect = e.getBoundingClientRect();
return [rect.left, rect.top, index];
});
const overlappedIndex = {};
// essentially pairs of vertices closest to each other
for (let { source, target } of d3.geom.voronoi().links(vertices)) {
if (Math.sqrt(Math.pow(source[0] - target[0], 2) + Math.pow(source[1] - target[1], 2)) < DOT_OVERLAP_DISTANCE) {
// if they overlap, mark both as overlapped
overlappedIndex[source[2]] = overlappedIndex[target[2]] = true;
}
}
const total = vertices.length;
const overlapping = Object.keys(overlappedIndex).length;
enableDots = overlapping < DOT_OVERLAP_COUNT_LIMIT || (overlapping / total) < DOT_OVERLAP_RATIO;
}
chart.svg()
.classed("enable-dots", enableDots)
.classed("enable-dots-onhover", !enableDots);
}
const VORONOI_TARGET_RADIUS = 25;
const VORONOI_MAX_POINTS = 300;
/// dispatchUIEvent used below in the "Voroni Hover" stuff
function dispatchUIEvent(element, eventName) {
let e = document.createEvent("UIEvents");
// $FlowFixMe
e.initUIEvent(eventName, true, true, window, 1);
element.dispatchEvent(e);
}
function onRenderVoronoiHover(chart) {
const parent = chart.svg().select("svg > g");
const dots = chart.svg().selectAll(".sub .dc-tooltip .dot")[0];
if (dots.length === 0 || dots.length > VORONOI_MAX_POINTS) {
return;
}
const originRect = chart.svg().node().getBoundingClientRect();
const vertices = dots.map(e => {
let { top, left, width, height } = e.getBoundingClientRect();
let px = (left + width / 2) - originRect.left;
let py = (top + height / 2) - originRect.top;
return [px, py, e];
});
// HACK Atte Keinänen 8/8/17: For some reason the parent node is not present in Jest/Enzyme tests
// so simply return empty width and height for preventing the need to do bigger hacks in test code
const { width, height } = parent.node() ? parent.node().getBBox() : { width: 0, height: 0 };
const voronoi = d3.geom.voronoi().clipExtent([[0,0], [width, height]]);
// circular clip paths to limit distance from actual point
parent.append("svg:g")
.selectAll("clipPath")
.data(vertices)
.enter().append("svg:clipPath")
.attr("id", (d, i) => "clip-" + i)
.append("svg:circle")
.attr('cx', (d) => d[0])
.attr('cy', (d) => d[1])
.attr('r', VORONOI_TARGET_RADIUS);
// voronoi layout with clip paths applied
parent.append("svg:g")
.classed("voronoi", true)
.selectAll("path")
.data(voronoi(vertices), (d) => d&&d.join(","))
.enter().append("svg:path")
.filter((d) => d != undefined)
.attr("d", (d) => "M" + d.join("L") + "Z")
.attr("clip-path", (d,i) => clipPathReference("clip-" + i))
.on("mousemove", ({ point }) => {
let e = point[2];
dispatchUIEvent(e, "mousemove");
d3.select(e).classed("hover", true);
})
.on("mouseleave", ({ point }) => {
let e = point[2];
dispatchUIEvent(e, "mouseleave");
d3.select(e).classed("hover", false);
})
.on("click", ({ point }) => {
let e = point[2];
dispatchUIEvent(e, "click");
})
.order();
}
function onRenderCleanupGoal(chart, onGoalHover, isSplitAxis) {
// 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) {
// stretch the goal line all the way across, use x axis as reference
let xAxisLine = chart.selectAll(".axis.x .domain")[0][0];
// HACK Atte Keinänen 8/8/17: For some reason getBBox method is not present in Jest/Enzyme tests
if (xAxisLine && goalLine.getBBox) {
goalLine.setAttribute("d", `M0,${goalLine.getBBox().y}L${xAxisLine.getBBox().width},${goalLine.getBBox().y}`)
}
let { x, y, width } = goalLine.getBBox ? goalLine.getBBox() : { x: 0, y: 0, width: 0 };
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); })
}
}
function onRenderHideDisabledLabels(chart, settings) {
if (!settings["graph.x_axis.labels_enabled"]) {
chart.selectAll(".x-axis-label").remove();
}
if (!settings["graph.y_axis.labels_enabled"]) {
chart.selectAll(".y-axis-label").remove();
}
}
function onRenderHideDisabledAxis(chart, settings) {
if (!settings["graph.x_axis.axis_enabled"]) {
chart.selectAll(".axis.x").remove();
}
if (!settings["graph.y_axis.axis_enabled"]) {
chart.selectAll(".axis.y, .axis.yr").remove();
}
}
function onRenderHideBadAxis(chart) {
if (chart.selectAll(".axis.x .tick")[0].length === 1) {
chart.selectAll(".axis.x").remove();
}
}
function onRenderDisableClickFiltering(chart) {
chart.selectAll("rect.bar")
.on("click", (d) => {
chart.filter(null);
chart.filter(d.key);
});
}
function onRenderFixStackZIndex(chart) {
// 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 onRenderSetClassName(chart, isStacked) {
chart.svg().classed("stacked", isStacked);
}
// the various steps that get called
function onRender(chart, settings, onGoalHover, isSplitAxis, isStacked) {
onRenderRemoveClipPath(chart);
onRenderMoveContentToTop(chart);
onRenderSetDotStyle(chart);
onRenderEnableDots(chart, settings);
onRenderVoronoiHover(chart);
onRenderCleanupGoal(chart, onGoalHover, isSplitAxis); // do this before hiding x-axis
onRenderHideDisabledLabels(chart, settings);
onRenderHideDisabledAxis(chart, settings);
onRenderHideBadAxis(chart);
onRenderDisableClickFiltering(chart);
onRenderFixStackZIndex(chart);
onRenderSetClassName(chart, isStacked);
}
// +-------------------------------------------------------------------------------------------------------------------+
// | BEFORE RENDER |
// +-------------------------------------------------------------------------------------------------------------------+
// run these first so the rest of the margin computations take it into account
function beforeRenderHideDisabledAxesAndLabels(chart, settings) {
onRenderHideDisabledLabels(chart, settings);
onRenderHideDisabledAxis(chart, settings);
onRenderHideBadAxis(chart);
}
// min margin
const MARGIN_TOP_MIN = 20; // needs to be large enough for goal line text
const MARGIN_BOTTOM_MIN = 10;
const MARGIN_HORIZONTAL_MIN = 20;
// extra padding for axis
const X_AXIS_PADDING = 0;
const Y_AXIS_PADDING = 8;
function adjustMargin(chart, margin, direction, padding, axisSelector, labelSelector) {
const axis = chart.select(axisSelector).node();
const label = chart.select(labelSelector).node();
const axisSize = axis ? axis.getBoundingClientRect()[direction] + 10 : 0;
const labelSize = label ? label.getBoundingClientRect()[direction] + 5 : 0;
chart.margins()[margin] = axisSize + labelSize + padding;
}
function computeMinHorizontalMargins(chart) {
let min = { left: 0, right: 0 };
const ticks = chart.selectAll(".axis.x .tick text")[0];
if (ticks.length > 0) {
const chartRect = chart.select("svg").node().getBoundingClientRect();
min.left = chart.margins().left - (ticks[0].getBoundingClientRect().left - chartRect.left);
min.right = chart.margins().right - (chartRect.right - ticks[ticks.length - 1].getBoundingClientRect().right);
}
return min;
}
function beforeRenderFixMargins(chart, settings) {
// run before adjusting margins
const mins = computeMinHorizontalMargins(chart);
// adjust the margins to fit the X and Y axis tick and label sizes, if enabled
adjustMargin(chart, "bottom", "height", X_AXIS_PADDING, ".axis.x", ".x-axis-label", settings["graph.x_axis.labels_enabled"]);
adjustMargin(chart, "left", "width", Y_AXIS_PADDING, ".axis.y", ".y-axis-label.y-label", settings["graph.y_axis.labels_enabled"]);
adjustMargin(chart, "right", "width", 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
chart.margins().top = Math.max(MARGIN_TOP_MIN, chart.margins().top);
chart.margins().left = Math.max(MARGIN_HORIZONTAL_MIN, chart.margins().left, mins.left);
chart.margins().right = Math.max(MARGIN_HORIZONTAL_MIN, chart.margins().right, mins.right);
chart.margins().bottom = Math.max(MARGIN_BOTTOM_MIN, chart.margins().bottom);
}
// collection of function calls that get made *before* we tell the Chart to render
function beforeRender(chart, settings) {
beforeRenderHideDisabledAxesAndLabels(chart, settings);
beforeRenderFixMargins(chart, settings);
}
// +-------------------------------------------------------------------------------------------------------------------+
// | PUTTING IT ALL TOGETHER |
// +-------------------------------------------------------------------------------------------------------------------+
/// 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);
chart.on("renderlet.on-render", () => onRender(chart, settings, onGoalHover, isSplitAxis, isStacked));
chart.render();
}
/// Logic for rendering a rows chart.
import crossfilter from "crossfilter";
import d3 from "d3";
import dc from "dc";
import { formatValue } from "metabase/lib/formatting";
import { initChart, forceSortedGroup, makeIndexMap } from "./renderer_utils";
import { getFriendlyName } from "./utils";
export default function rowRenderer(
element,
{ settings, series, onHoverChange, onVisualizationClick, height }
) {
const { cols } = series[0].data;
if (series.length > 1) {
throw new Error("Row chart does not support multiple series");
}
const chart = dc.rowChart(element);
// disable clicks
chart.onClick = () => {};
const colors = settings["graph.colors"];
const formatDimension = (row) =>
formatValue(row[0], { column: cols[0], type: "axis" })
// dc.js doesn't give us a way to format the row labels from unformatted data, so we have to
// do it here then construct a mapping to get the original dimension for tooltipsd/clicks
const rows = series[0].data.rows.map(row => [
formatDimension(row),
row[1]
]);
const formattedDimensionMap = new Map(rows.map(([formattedDimension], index) => [
formattedDimension,
series[0].data.rows[index][0]
]))
const dataset = crossfilter(rows);
const dimension = dataset.dimension(d => d[0]);
const group = dimension.group().reduceSum(d => d[1]);
const xDomain = d3.extent(rows, d => d[1]);
const yValues = rows.map(d => d[0]);
forceSortedGroup(group, makeIndexMap(yValues));
initChart(chart, element);
chart.on("renderlet.tooltips", chart => {
if (onHoverChange) {
chart.selectAll(".row rect").on("mousemove", (d, i) => {
onHoverChange && onHoverChange({
// for single series bar charts, fade the series and highlght the hovered element with CSS
index: -1,
event: d3.event,
data: [
{ key: getFriendlyName(cols[0]), value: formattedDimensionMap.get(d.key), col: cols[0] },
{ key: getFriendlyName(cols[1]), value: d.value, col: cols[1] }
]
});
}).on("mouseleave", () => {
onHoverChange && onHoverChange(null);
});
}
if (onVisualizationClick) {
chart.selectAll(".row rect").on("click", function(d) {
onVisualizationClick({
value: d.value,
column: cols[1],
dimensions: [{
value: formattedDimensionMap.get(d.key),
column: cols[0]
}],
element: this
})
});
}
});
chart
.ordinalColors([ colors[0] ])
.x(d3.scale.linear().domain(xDomain))
.elasticX(true)
.dimension(dimension)
.group(group)
.ordering(d => d.index);
let labelPadHorizontal = 5;
let labelPadVertical = 1;
let labelsOutside = false;
chart.on("renderlet.bar-labels", chart => {
chart
.selectAll("g.row text")
.attr("text-anchor", labelsOutside ? "end" : "start")
.attr("x", labelsOutside ? -labelPadHorizontal : labelPadHorizontal)
.classed(labelsOutside ? "outside" : "inside", true);
});
if (settings["graph.y_axis.labels_enabled"]) {
chart.on("renderlet.axis-labels", chart => {
chart
.svg()
.append("text")
.attr("class", "x-axis-label")
.attr("text-anchor", "middle")
.attr("x", chart.width() / 2)
.attr("y", chart.height() - 10)
.text(settings["graph.y_axis.title_text"]);
});
}
// inital render
chart.render();
// bottom label height
let axisLabelHeight = 0;
if (settings["graph.y_axis.labels_enabled"]) {
axisLabelHeight = chart
.select(".x-axis-label")
.node()
.getBoundingClientRect().height;
chart.margins().bottom += axisLabelHeight;
}
// cap number of rows to fit
let rects = chart.selectAll(".row rect")[0];
let containerHeight = rects[rects.length - 1].getBoundingClientRect().bottom -
rects[0].getBoundingClientRect().top;
let maxTextHeight = Math.max(
...chart.selectAll("g.row text")[0].map(
e => e.getBoundingClientRect().height
)
);
let rowHeight = maxTextHeight + chart.gap() + labelPadVertical * 2;
let cap = Math.max(1, Math.floor(containerHeight / rowHeight));
chart.cap(cap);
chart.render();
// check if labels overflow after rendering correct number of rows
let maxTextWidth = 0;
for (const elem of chart.selectAll("g.row")[0]) {
let rect = elem.querySelector("rect").getBoundingClientRect();
let text = elem.querySelector("text").getBoundingClientRect();
maxTextWidth = Math.max(maxTextWidth, text.width);
if (rect.width < text.width + labelPadHorizontal * 2) {
labelsOutside = true;
}
}
if (labelsOutside) {
chart.margins().left += maxTextWidth;
chart.render();
}
}
/// functions for "applying" axes to charts, whatever that means.
import _ from "underscore";
import d3 from "d3";
import dc from "dc";
import moment from "moment";
import { datasetContainsNoResults } from "metabase/lib/dataset";
import { formatValue } from "metabase/lib/formatting";
import { parseTimestamp } from "metabase/lib/time";
import { computeTimeseriesTicksInterval } from "./timeseries";
import { getFriendlyName } from "./utils";
const MIN_PIXELS_PER_TICK = { x: 100, y: 32 };
// label offset (doesn't increase padding)
const X_LABEL_PADDING = 10;
const Y_LABEL_PADDING = 22;
function adjustTicksIfNeeded(axis, axisSize: number, minPixelsPerTick: number) {
const ticks = axis.ticks();
// d3.js is dumb and sometimes numTicks is a number like 10 and other times it is an Array like [10]
// if it's an array then convert to a num
const numTicks: number = Array.isArray(ticks) ? ticks[0] : ticks;
if ((axisSize / numTicks) < minPixelsPerTick) {
axis.ticks(Math.round(axisSize / minPixelsPerTick));
}
}
export function applyChartTimeseriesXAxis(chart, settings, series, { xValues, xDomain, xInterval }) {
// find the first nonempty single series
// $FlowFixMe
const firstSeries: SingleSeries = _.find(series, (s) => !datasetContainsNoResults(s.data));
// setup an x-axis where the dimension is a timeseries
let dimensionColumn = firstSeries.data.cols[0];
// get the data's timezone offset from the first row
let dataOffset = parseTimestamp(firstSeries.data.rows[0][0]).utcOffset() / 60;
// compute the data interval
let dataInterval = xInterval;
let tickInterval = dataInterval;
if (settings["graph.x_axis.labels_enabled"]) {
chart.xAxisLabel(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 (dimensionColumn.unit == null) {
dimensionColumn = { ...dimensionColumn, unit: dataInterval.interval };
}
// special handling for weeks
// TODO: are there any other cases where we should do this?
if (dataInterval.interval === "week") {
// if tick interval is compressed then show months instead of weeks because they're nicer formatted
const newTickInterval = computeTimeseriesTicksInterval(xDomain, tickInterval, chart.width(), MIN_PIXELS_PER_TICK.x);
if (newTickInterval.interval !== tickInterval.interval || newTickInterval.count !== tickInterval.count) {
dimensionColumn = { ...dimensionColumn, unit: "month" },
tickInterval = { interval: "month", count: 1 };
}
}
chart.xAxis().tickFormat(timestamp => {
// timestamp is a plain Date object which discards the timezone,
// so add it back in so it's formatted correctly
const timestampFixed = moment(timestamp).utcOffset(dataOffset).format();
return formatValue(timestampFixed, { column: dimensionColumn, type: "axis" })
});
// Compute a sane interval to display based on the data granularity, domain, and chart width
tickInterval = computeTimeseriesTicksInterval(xDomain, tickInterval, 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);
// set the x scale
chart.x(d3.time.scale.utc().domain(xDomain));//.nice(d3.time[dataInterval.interval]));
// set the x units (used to compute bar size)
chart.xUnits((start, stop) => Math.ceil(1 + moment(stop).diff(start, dataInterval.interval) / dataInterval.count));
}
export function applyChartQuantitativeXAxis(chart, settings, series, { xValues, xDomain, xInterval }) {
// find the first nonempty single series
// $FlowFixMe
const firstSeries: SingleSeries = _.find(series, (s) => !datasetContainsNoResults(s.data));
const dimensionColumn = firstSeries.data.cols[0];
if (settings["graph.x_axis.labels_enabled"]) {
chart.xAxisLabel(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"]);
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));
}
export function applyChartOrdinalXAxis(chart, settings, series, { xValues }) {
// find the first nonempty single series
// $FlowFixMe
const firstSeries: SingleSeries = _.find(series, (s) => !datasetContainsNoResults(s.data));
const dimensionColumn = firstSeries.data.cols[0];
if (settings["graph.x_axis.labels_enabled"]) {
chart.xAxisLabel(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"]);
chart.xAxis().ticks(xValues.length);
adjustTicksIfNeeded(chart.xAxis(), chart.width(), MIN_PIXELS_PER_TICK.x);
// 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()
let numTicks = chart.xAxis().ticks();
if (Array.isArray(numTicks)) {
numTicks = numTicks[0];
}
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 }));
} else {
chart.xAxis().ticks(0);
chart.xAxis().tickFormat('');
}
chart.x(d3.scale.ordinal().domain(xValues))
.xUnits(dc.units.ordinal);
}
export function applyChartYAxis(chart, settings, 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]
};
} 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
};
}
if (axis.setting("labels_enabled")) {
// left
if (axis.setting("title_text")) {
axis.label(axis.setting("title_text"), Y_LABEL_PADDING);
} 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], Y_LABEL_PADDING);
}
}
}
if (axis.setting("axis_enabled")) {
// special case for normalized stacked charts
if (settings["stackable.stack_type"] === "normalized") {
axis.axis().tickFormat(value => (value * 100) + "%");
}
chart.renderHorizontalGridLines(true);
adjustTicksIfNeeded(axis.axis(), chart.height(), MIN_PIXELS_PER_TICK.y);
} else {
axis.axis().ticks(0);
}
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 {
scale = d3.scale.linear();
}
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 {
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")]))
}
}
/// code to "apply" chart tooltips. (How does one apply a tooltip?)
import _ from "underscore";
import d3 from "d3";
import { formatValue } from "metabase/lib/formatting";
import type { ClickObject } from "metabase/meta/types/Visualization"
import { determineSeriesIndexFromElement } from "./tooltip";
import { getFriendlyName } from "./utils";
export function applyChartTooltips(chart, series, isStacked, isNormalized, isScalarSeries, onHoverChange, onVisualizationClick) {
let [{ data: { cols } }] = series;
chart.on("renderlet.tooltips", function(chart) {
chart.selectAll("title").remove();
if (onHoverChange) {
chart.selectAll(".bar, .dot, .area, .line, .bubble")
.on("mousemove", function(d, i) {
const seriesIndex = determineSeriesIndexFromElement(this, isStacked);
const card = series[seriesIndex].card;
const isSingleSeriesBar = this.classList.contains("bar") && series.length === 1;
const isArea = this.classList.contains("area");
let data = [];
if (Array.isArray(d.key)) { // scatter
if (d.key._origin) {
data = d.key._origin.row.map((value, index) => {
const col = d.key._origin.cols[index];
return { key: getFriendlyName(col), value: value, col };
});
} else {
data = d.key.map((value, index) => (
{ key: getFriendlyName(cols[index]), value: value, col: cols[index] }
));
}
} else if (d.data) { // line, area, bar
if (!isSingleSeriesBar) {
cols = series[seriesIndex].data.cols;
}
data = [
{
key: getFriendlyName(cols[0]),
value: d.data.key,
col: cols[0]
},
{
key: getFriendlyName(cols[1]),
value: isNormalized
? `${formatValue(d.data.value) * 100}%`
: d.data.value,
col: cols[1]
}
];
}
if (data && series.length > 1) {
if (card._breakoutColumn) {
data.unshift({
key: getFriendlyName(card._breakoutColumn),
value: card._breakoutValue,
col: card._breakoutColumn
});
}
}
data = _.uniq(data, (d) => d.col);
onHoverChange({
// for single series bar charts, fade the series and highlght the hovered element with CSS
index: isSingleSeriesBar ? -1 : seriesIndex,
// for area charts, use the mouse location rather than the DOM element
element: isArea ? null : this,
event: isArea ? d3.event : null,
data: data.length > 0 ? data : null,
});
})
.on("mouseleave", function() {
if (!onHoverChange) {
return;
}
onHoverChange(null);
})
}
if (onVisualizationClick) {
const onClick = function(d) {
const seriesIndex = determineSeriesIndexFromElement(this, isStacked);
const card = series[seriesIndex].card;
const isSingleSeriesBar = this.classList.contains("bar") && series.length === 1;
let clicked: ?ClickObject;
if (Array.isArray(d.key)) { // scatter
clicked = {
value: d.key[2],
column: cols[2],
dimensions: [
{ value: d.key[0], column: cols[0] },
{ value: d.key[1], column: cols[1] }
].filter(({ column }) =>
// don't include aggregations since we can't filter on them
column.source !== "aggregation"
),
origin: d.key._origin
}
} else if (isScalarSeries) {
// special case for multi-series scalar series, which should be treated as scalars
clicked = {
value: d.data.value,
column: series[seriesIndex].data.cols[1]
};
} else if (d.data) { // line, area, bar
if (!isSingleSeriesBar) {
cols = series[seriesIndex].data.cols;
}
clicked = {
value: d.data.value,
column: cols[1],
dimensions: [
{ value: d.data.key, column: cols[0] }
]
}
} else {
clicked = {
dimensions: []
};
}
// handle multiseries
if (clicked && series.length > 1) {
if (card._breakoutColumn) {
// $FlowFixMe
clicked.dimensions.push({
value: card._breakoutValue,
column: card._breakoutColumn
});
}
}
if (card._seriesIndex != null) {
// $FlowFixMe
clicked.seriesIndex = card._seriesIndex;
}
if (clicked) {
const isLine = this.classList.contains("dot");
onVisualizationClick({
...clicked,
element: isLine ? this : null,
event: isLine ? null : d3.event,
});
}
}
// for some reason interaction with brush requires we use click for .dot and .bubble but mousedown for bar
chart.selectAll(".dot, .bubble")
.style({ "cursor": "pointer" })
.on("click", onClick);
chart.selectAll(".bar")
.style({ "cursor": "pointer" })
.on("mousedown", onClick);
}
});
}
// code for filling in the missing values in a set of "datas"
import d3 from "d3";
import moment from "moment";
import { isTimeseries, isQuantitative, isHistogram, isHistogramBar } from "./renderer_utils";
// max number of points to "fill"
// TODO: base on pixel width of chart?
const MAX_FILL_COUNT = 10000;
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 fillMissingValuesInDatas(props, { xValues, xDomain, xInterval }, datas) {
const { settings } = props;
if (settings["line.missing"] === "zero" || settings["line.missing"] === "none") {
const fillValue = settings["line.missing"] === "zero" ? 0 : null;
if (isTimeseries(settings)) {
// $FlowFixMe
const { interval, count } = xInterval;
if (count <= MAX_FILL_COUNT) {
// replace xValues with
xValues = d3.time[interval]
.range(xDomain[0], moment(xDomain[1]).add(1, "ms"), count)
.map(d => moment(d));
datas = fillMissingValues(
datas,
xValues,
fillValue,
(m) => d3.round(m.toDate().getTime(), -1) // sometimes rounds up 1ms?
);
}
}
if (isQuantitative(settings) || isHistogram(settings)) {
// $FlowFixMe
const count = Math.abs((xDomain[1] - xDomain[0]) / xInterval);
if (count <= MAX_FILL_COUNT) {
let [start, end] = xDomain;
if (isHistogramBar(props)) {
// NOTE: intentionally add an end point for bar histograms
// $FlowFixMe
end += xInterval * 1.5
} else {
// NOTE: avoid including endpoint due to floating point error
// $FlowFixMe
end += xInterval * 0.5
}
xValues = d3.range(start, end, xInterval);
datas = fillMissingValues(
datas,
xValues,
fillValue,
// NOTE: normalize to xInterval to avoid floating point issues
(v) => Math.round(v / xInterval)
);
}
} else {
datas = fillMissingValues(
datas,
xValues,
fillValue
);
}
}
}
/// Utility functions used by both the LineAreaBar renderer and the RowRenderer
import _ from "underscore";
import { getIn } from "icepick";
import { datasetContainsNoResults } from "metabase/lib/dataset";
import { parseTimestamp } from "metabase/lib/time";
import { dimensionIsNumeric } from "./numeric";
import { dimensionIsTimeseries } from "./timeseries";
import { getAvailableCanvasWidth, getAvailableCanvasHeight } from "./utils";
export const NULL_DIMENSION_WARNING = "Data includes missing dimension values.";
export function initChart(chart, element) {
// set the bounds
chart.width(getAvailableCanvasWidth(element));
chart.height(getAvailableCanvasHeight(element));
// disable animations
chart.transitionDuration(0);
// disable brush
if (chart.brushOn) {
chart.brushOn(false);
}
}
export function makeIndexMap(values: Array<Value>): Map<Value, number> {
let indexMap = new Map()
for (const [index, key] of values.entries()) {
indexMap.set(key, index);
}
return indexMap;
}
type CrossfilterGroup = {
top: (n: number) => { key: any, value: any },
all: () => { key: any, value: any },
}
// 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.
export function forceSortedGroup(group: CrossfilterGroup, indexMap: Map<Value, number>): void {
// $FlowFixMe
const sorted = group.top(Infinity).sort((a, b) => indexMap.get(a.key) - indexMap.get(b.key));
for (let i = 0; i < sorted.length; i++) {
sorted[i].index = i;
}
group.all = () => sorted;
}
export function forceSortedGroupsOfGroups(groupsOfGroups: CrossfilterGroup[][], indexMap: Map<Value, number>): void {
for (const groups of groupsOfGroups) {
for (const group of groups) {
forceSortedGroup(group, indexMap)
}
}
}
/*
* The following functions are actually just used by LineAreaBarRenderer but moved here in interest of making that namespace more concise
*/
export function reduceGroup(group, key, warnUnaggregated) {
return group.reduce(
(acc, d) => {
if (acc == null && d[key] == null) {
return null;
} else {
if (acc != null) {
warnUnaggregated();
return acc + (d[key] || 0);
} else {
return (d[key] || 0);
}
}
},
(acc, d) => {
if (acc == null && d[key] == null) {
return null;
} else {
if (acc != null) {
warnUnaggregated();
return acc - (d[key] || 0);
} else {
return - (d[key] || 0);
}
}
},
() => null
);
}
// Crossfilter calls toString on each moment object, which calls format(), which is very slow.
// Replace toString with a function that just returns the unparsed ISO input date, since that works
// just as well and is much faster
function moment_fast_toString() {
return this._i;
}
export function HACK_parseTimestamp(value, unit, warn) {
if (value == null) {
warn(NULL_DIMENSION_WARNING);
return null;
} else {
let m = parseTimestamp(value, unit);
m.toString = moment_fast_toString
return m;
}
}
/************************************************************ PROPERTIES ************************************************************/
export const isTimeseries = (settings) => settings["graph.x_axis.scale"] === "timeseries";
export const isQuantitative = (settings) => ["linear", "log", "pow"].indexOf(settings["graph.x_axis.scale"]) >= 0;
export const isHistogram = (settings) => settings["graph.x_axis.scale"] === "histogram";
export const isOrdinal = (settings) => !isTimeseries(settings) && !isHistogram(settings);
// bar histograms have special tick formatting:
// * aligned with beginning of bar to show bin boundaries
// * label only shows beginning value of bin
// * includes an extra tick at the end for the end of the last bin
export const isHistogramBar = ({ settings, chartType }) => isHistogram(settings) && chartType === "bar";
export const isStacked = (settings, datas) => settings["stackable.stack_type"] && datas.length > 1;
export const isNormalized = (settings, datas) => isStacked(settings, datas) && settings["stackable.stack_type"] === "normalized";
// find the first nonempty single series
export const getFirstNonEmptySeries = (series) => _.find(series, (s) => !datasetContainsNoResults(s.data));
export const isDimensionTimeseries = (series) => dimensionIsTimeseries(getFirstNonEmptySeries(series).data);
export const isDimensionNumeric = (series) => dimensionIsNumeric(getFirstNonEmptySeries(series).data);
function hasRemappingAndValuesAreStrings({ cols }, i = 0) {
const column = cols[i];
if (column.remapping && column.remapping.size > 0) {
// We have remapped values, so check their type for determining whether the dimension is numeric
// ES6 Map makes the lookup of first value a little verbose
return typeof column.remapping.values().next().value === "string";
} else {
return false
}
}
export const isRemappedToString = (series) => hasRemappingAndValuesAreStrings(getFirstNonEmptySeries(series).data);
// is this a dashboard multiseries?
// TODO: better way to detect this?
export const isMultiCardSeries = (series) => (
series.length > 1 && getIn(series, [0, "card", "id"]) !== getIn(series, [1, "card", "id"])
);
......@@ -101,12 +101,12 @@ export function computeTimeseriesDataInverval(xValues, unit) {
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);
const maxTickCount = Math.round(chartWidth / minPixels);
let index = _.findIndex(TIMESERIES_INTERVALS, ({ interval, count }) => interval === xInterval.interval && count === xInterval.count);
while (index < TIMESERIES_INTERVALS.length - 1) {
let interval = TIMESERIES_INTERVALS[index];
let intervalMs = moment(0).add(interval.count, interval.interval).valueOf();
let tickCount = (xDomain[1] - xDomain[0]) / intervalMs;
const interval = TIMESERIES_INTERVALS[index];
const intervalMs = moment(0).add(interval.count, interval.interval).valueOf();
const tickCount = (xDomain[1] - xDomain[0]) / intervalMs;
if (tickCount <= maxTickCount) {
break;
}
......
......@@ -2,7 +2,7 @@
import LineAreaBarChart from "../components/LineAreaBarChart.jsx";
import { rowRenderer } from "../lib/LineAreaBarRenderer";
import rowRenderer from "../lib/RowRenderer.js";
import {
GRAPH_DATA_SETTINGS,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment