blob: 6f62111860e92b3395ab9f05e8897c130d3c97c4 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import d3 from 'd3';
import d3tip from 'd3-tip';
import dompurify from 'dompurify';
import { smartDateFormatter, getNumberFormatter } from '@superset-ui/core';
// Regexp for the label added to time shifted series
// (1 hour offset, 2 days offset, etc.)
const TIME_SHIFT_PATTERN = /\d+ \w+ offset/;
const ANIMATION_TIME = 1000;
export function cleanColorInput(value) {
// for superset series that should have the same color
return String(value)
.trim()
.replace(' (right axis)', '')
.split(', ')
.filter(k => !TIME_SHIFT_PATTERN.test(k))
.join(', ');
}
/**
* If format is smart_date, format date
* Otherwise, format number with the given format name
* @param {*} format
*/
export function getTimeOrNumberFormatter(format) {
return format === 'smart_date' ? smartDateFormatter : getNumberFormatter(format);
}
export function drawBarValues(svg, data, stacked, axisFormat) {
const format = getNumberFormatter(axisFormat);
const countSeriesDisplayed = data.filter(d => !d.disabled).length;
const totalStackedValues =
stacked && data.length !== 0
? data[0].values.map((bar, iBar) => {
const bars = data.filter(series => !series.disabled).map(series => series.values[iBar]);
return d3.sum(bars, d => d.y);
})
: [];
svg.selectAll('.bar-chart-label-group').remove();
setTimeout(() => {
svg.selectAll('.bar-chart-label-group').remove();
const groupLabels = svg
.select('g.nv-barsWrap')
.append('g')
.attr('class', 'bar-chart-label-group');
svg
.selectAll('g.nv-group')
.filter((d, i) => !stacked || i === countSeriesDisplayed - 1)
.selectAll('rect')
.each(function each(d, index) {
const rectObj = d3.select(this);
const transformAttr = rectObj.attr('transform');
const xPos = parseFloat(rectObj.attr('x'));
const yPos = parseFloat(rectObj.attr('y'));
const rectWidth = parseFloat(rectObj.attr('width'));
const rectHeight = parseFloat(rectObj.attr('height'));
const textEls = groupLabels
.append('text')
.text(format(stacked ? totalStackedValues[index] : d.y))
.attr('transform', transformAttr)
.attr('class', 'bar-chart-label');
// fine tune text position
const bbox = textEls.node().getBBox();
const labelWidth = bbox.width;
const labelHeight = bbox.height;
textEls.attr('x', xPos + rectWidth / 2 - labelWidth / 2);
if (rectObj.attr('class').includes('positive')) {
textEls.attr('y', yPos - 5);
} else {
textEls.attr('y', yPos + rectHeight + labelHeight);
}
});
}, ANIMATION_TIME);
}
// Formats the series key to account for a possible NULL value
function getFormattedKey(seriesKey, shouldDompurify) {
if (seriesKey === '<NULL>') {
return `&lt;${seriesKey.slice(1, -1)}&gt;`;
}
return shouldDompurify ? dompurify.sanitize(seriesKey) : seriesKey;
}
// Custom sorted tooltip
// use a verbose formatter for times
export function generateRichLineTooltipContent(d, timeFormatter, valueFormatter) {
let tooltip = '';
tooltip +=
"<table><thead><tr><td colspan='3'>" +
`<strong class='x-value'>${timeFormatter(d.value)}</strong>` +
'</td></tr></thead><tbody>';
d.series.sort((a, b) => (a.value >= b.value ? -1 : 1));
d.series.forEach(series => {
const key = getFormattedKey(series.key, true);
tooltip +=
`<tr class="${series.highlight ? 'emph' : ''}">` +
`<td class='legend-color-guide' style="opacity: ${series.highlight ? '1' : '0.75'};"">` +
'<div ' +
`style="border: 2px solid ${series.highlight ? 'black' : 'transparent'}; background-color: ${
series.color
};"` +
'></div>' +
'</td>' +
`<td>${key}</td>` +
`<td>${valueFormatter(series.value)}</td>` +
'</tr>';
});
tooltip += '</tbody></table>';
return dompurify.sanitize(tooltip);
}
export function generateCompareTooltipContent(d, valueFormatter) {
let tooltip = '';
tooltip +=
"<table><thead><tr><td colspan='3'>" +
`<strong class='x-value'>${d.value}</strong>` +
'</td></tr></thead><tbody>';
d.series.sort((a, b) => (a.value >= b.value ? -1 : 1));
d.series.forEach(series => {
const key = getFormattedKey(series.key, true);
tooltip +=
`<tr class="${series.highlight ? 'emph' : ''}">` +
`<td class='legend-color-guide' style="opacity: ${series.highlight ? '1' : '0.75'};"">` +
'<div ' +
`style="border: 2px solid ${series.highlight ? 'black' : 'transparent'}; background-color: ${
series.color
};"` +
'></div>' +
'</td>' +
`<td>${key}</td>` +
`<td>${valueFormatter(series.value)}</td>` +
'</tr>';
});
tooltip += '</tbody></table>';
return dompurify.sanitize(tooltip);
}
export function generateAreaChartTooltipContent(d, timeFormatter, valueFormatter, chart) {
const total =
chart.style() === 'expand'
? // expand mode does not include total row
d3.sum(d.series, s => s.value)
: // other modes include total row at the end
d.series[d.series.length - 1].value;
let tooltip = '';
tooltip +=
"<table><thead><tr><td colspan='4'>" +
`<strong class='x-value'>${timeFormatter(d.value)}</strong>` +
'</td></tr></thead><tbody>' +
'<tr class="tooltip-header"><td></td><td>Category</td><td>Value</td><td>% to total</td></tr>';
d.series.forEach(series => {
const key = getFormattedKey(series.key, true);
let trClass = '';
if (series.highlight) {
trClass = 'superset-legacy-chart-nvd3-tr-highlight';
} else if (series.key === 'TOTAL') {
trClass = 'superset-legacy-chart-nvd3-tr-total';
}
tooltip +=
`<tr class="${trClass}" style="border-color: ${series.color}">` +
`<td style="color: ${series.color}">${series.key === 'TOTAL' ? '' : '&#9724;'}</td>` +
`<td>${key}</td>` +
`<td>${valueFormatter(series.value)}</td>` +
`<td>${((100 * series.value) / total).toFixed(2)}%</td>` +
'</tr>';
});
tooltip += '</tbody></table>';
return dompurify.sanitize(tooltip);
}
export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) {
const tooltipTitle = xFormatter(d.value);
let tooltip = '';
tooltip +=
"<table><thead><tr><td colspan='3'>" +
`<strong class='x-value'>${tooltipTitle}</strong>` +
'</td></tr></thead><tbody>';
d.series.forEach((series, i) => {
const yFormatter = yFormatters[i];
const key = getFormattedKey(series.key, false);
tooltip +=
"<tr><td class='legend-color-guide'>" +
`<div style="background-color: ${series.color};"></div></td>` +
`<td class='key'>${key}</td>` +
`<td class='value'>${yFormatter(series.value)}</td></tr>`;
});
tooltip += '</tbody></table>';
return tooltip;
}
export function generateTimePivotTooltip(d, xFormatter, yFormatter) {
const tooltipTitle = xFormatter(d.value);
let tooltip = '';
tooltip +=
"<table><thead><tr><td colspan='3'>" +
`<strong class='x-value'>${tooltipTitle}</strong>` +
'</td></tr></thead><tbody>';
d.series.forEach(series => {
if (series.highlight) {
let label = '';
if (series.key === 'current') {
label = series.key;
} else {
label = `${series.key} of the selected frequency:`;
}
tooltip +=
"<tr><td class='legend-color-guide'>" +
`<div style="background-color: ${series.color};"></div></td>` +
`<td class='key'>${label}</td>` +
`<td class='value'>${yFormatter(series.value)}</td></tr>`;
}
});
tooltip += '</tbody></table>';
return dompurify.sanitize(tooltip);
}
function getLabel(stringOrObjectWithLabel) {
return stringOrObjectWithLabel.label || stringOrObjectWithLabel;
}
function createHTMLRow(col1, col2) {
return `<tr><td>${col1}</td><td>${col2}</td></tr>`;
}
export function generateBubbleTooltipContent({
point,
entity,
xField,
yField,
sizeField,
xFormatter,
yFormatter,
sizeFormatter,
}) {
let s = '<table>';
s +=
`<tr><td style="color: ${point.color};">` +
`<strong>${point[entity]}</strong> (${point.group})` +
'</td></tr>';
s += createHTMLRow(getLabel(xField), xFormatter(point.x));
s += createHTMLRow(getLabel(yField), yFormatter(point.y));
s += createHTMLRow(getLabel(sizeField), sizeFormatter(point.size));
s += '</table>';
return s;
}
// shouldRemove indicates whether the nvtooltips should be removed from the DOM
export function hideTooltips(shouldRemove) {
const targets = document.querySelectorAll('.nvtooltip');
if (targets.length > 0) {
// Only set opacity to 0 when hiding tooltips so they would reappear
// on hover, which sets the opacity to 1
targets.forEach(t => {
if (shouldRemove) {
t.remove();
} else {
// eslint-disable-next-line no-param-reassign
t.style.opacity = 0;
}
});
}
}
export function generateTooltipClassName(uuid) {
return `tooltip-${uuid}`;
}
export function removeTooltip(uuid) {
const classSelector = `.${generateTooltipClassName(uuid)}`;
const target = document.querySelector(classSelector);
if (target) {
target.remove();
}
}
export function wrapTooltip(chart) {
const tooltipLayer =
chart.useInteractiveGuideline && chart.useInteractiveGuideline()
? chart.interactiveLayer
: chart;
const tooltipGeneratorFunc = tooltipLayer.tooltip.contentGenerator();
tooltipLayer.tooltip.contentGenerator(d => {
let tooltip = `<div>`;
tooltip += tooltipGeneratorFunc(d);
tooltip += '</div>';
return tooltip;
});
}
export function tipFactory(layer) {
return d3tip()
.attr('class', `d3-tip ${layer.annotationTipClass || ''}`)
.direction('n')
.offset([-5, 0])
.html(d => {
if (!d) {
return '';
}
const title =
d[layer.titleColumn] && d[layer.titleColumn].length > 0
? `${d[layer.titleColumn]} - ${layer.name}`
: layer.name;
const body = Array.isArray(layer.descriptionColumns)
? layer.descriptionColumns.map(c => d[c])
: Object.values(d);
return `<div><strong>${title}</strong></div><br/><div>${body.join(', ')}</div>`;
});
}
export function getMaxLabelSize(svg, axisClass) {
// axis class = .nv-y2 // second y axis on dual line chart
// axis class = .nv-x // x axis on time series line chart
const tickTexts = svg.selectAll(`.${axisClass} g.tick text`);
if (tickTexts.length > 0) {
const lengths = tickTexts[0].map(text => text.getComputedTextLength());
return Math.ceil(Math.max(0, ...lengths));
}
return 0;
}
export function formatLabel(input, verboseMap = {}) {
// The input for label may be a string or an array of string
// When using the time shift feature, the label contains a '---' in the array
const verboseLookup = s => verboseMap[s] || s;
return Array.isArray(input) && input.length > 0
? input.map(l => (TIME_SHIFT_PATTERN.test(l) ? l : verboseLookup(l))).join(', ')
: verboseLookup(input);
}
const MIN_BAR_WIDTH = 18;
export function computeBarChartWidth(data, stacked, maxWidth) {
const barCount = stacked
? d3.max(data, d => d.values.length)
: d3.sum(data, d => d.values.length);
const barWidth = barCount * MIN_BAR_WIDTH;
return Math.max(barWidth, maxWidth);
}
export function tryNumify(s) {
// Attempts casting to Number, returns string when failing
const n = Number(s);
return Number.isNaN(n) ? s : n;
}
export function stringifyTimeRange(extent) {
if (extent.some(d => d.toISOString === undefined)) {
return null;
}
return extent.map(d => d.toISOString().slice(0, -1)).join(' : ');
}
export function setAxisShowMaxMin(axis, showminmax) {
if (axis && axis.showMaxMin && showminmax !== undefined) {
axis.showMaxMin(showminmax);
}
}
export function computeYDomain(data) {
if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0].values)) {
const extents = data.filter(d => !d.disabled).map(row => d3.extent(row.values, v => v.y));
const minOfMin = d3.min(extents, ([min]) => min);
const maxOfMax = d3.max(extents, ([, max]) => max);
return [minOfMin, maxOfMax];
}
return [0, 1];
}
export function computeStackedYDomain(data) {
if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0].values)) {
const series = data.filter(d => !d.disabled).map(d => d.values.map(v => v.y));
const stackedValues = series[0].map((_, i) => series.reduce((acc, cur) => acc + cur[i], 0));
return [Math.min(0, ...stackedValues), Math.max(0, ...stackedValues)];
}
return [0, 1];
}