airbnb/caravel

View on GitHub
superset-frontend/plugins/legacy-preset-chart-nvd3/src/utils.js

Summary

Maintainability
D
2 days
Test Coverage
/**
 * 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 {
  SMART_DATE_ID,
  getTimeFormatter,
  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_ID
    ? getTimeFormatter(SMART_DATE_ID)
    : 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);
    const isTotal = series.key === 'TOTAL';
    let trClass = '';
    if (series.highlight) {
      trClass = 'superset-legacy-chart-nvd3-tr-highlight';
    } else if (isTotal) {
      trClass = 'superset-legacy-chart-nvd3-tr-total';
    }
    tooltip +=
      `<tr class="${trClass}" style="border-color: ${series.color}">` +
      `<td style="color: ${series.color}">${isTotal ? '' : '&#9724;'}</td>` +
      `<td>${key}</td>` +
      `<td>${valueFormatter(isTotal ? total : series?.point?.y)}</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];
}