nnnick/Chart.js

View on GitHub
src/helpers/helpers.dom.ts

Summary

Maintainability
A
0 mins
Test Coverage
import type {ChartArea, Scale} from '../types/index.js';
import type Chart from '../core/core.controller.js';
import type {ChartEvent} from '../types.js';
import {INFINITY} from './helpers.math.js';

/**
 * Note: typedefs are auto-exported, so use a made-up `dom` namespace where
 * necessary to avoid duplicates with `export * from './helpers`; see
 * https://github.com/microsoft/TypeScript/issues/46011
 * @typedef { import('../core/core.controller.js').default } dom.Chart
 * @typedef { import('../../types').ChartEvent } ChartEvent
 */

/**
 * @private
 */
export function _isDomSupported(): boolean {
  return typeof window !== 'undefined' && typeof document !== 'undefined';
}

/**
 * @private
 */
export function _getParentNode(domNode: HTMLCanvasElement): HTMLCanvasElement {
  let parent = domNode.parentNode;
  if (parent && parent.toString() === '[object ShadowRoot]') {
    parent = (parent as ShadowRoot).host;
  }
  return parent as HTMLCanvasElement;
}

/**
 * convert max-width/max-height values that may be percentages into a number
 * @private
 */

function parseMaxStyle(styleValue: string | number, node: HTMLElement, parentProperty: string) {
  let valueInPixels: number;
  if (typeof styleValue === 'string') {
    valueInPixels = parseInt(styleValue, 10);

    if (styleValue.indexOf('%') !== -1) {
      // percentage * size in dimension
      valueInPixels = (valueInPixels / 100) * node.parentNode[parentProperty];
    }
  } else {
    valueInPixels = styleValue;
  }

  return valueInPixels;
}

const getComputedStyle = (element: HTMLElement): CSSStyleDeclaration =>
  element.ownerDocument.defaultView.getComputedStyle(element, null);

export function getStyle(el: HTMLElement, property: string): string {
  return getComputedStyle(el).getPropertyValue(property);
}

const positions = ['top', 'right', 'bottom', 'left'];
function getPositionedStyle(styles: CSSStyleDeclaration, style: string, suffix?: string): ChartArea {
  const result = {} as ChartArea;
  suffix = suffix ? '-' + suffix : '';
  for (let i = 0; i < 4; i++) {
    const pos = positions[i];
    result[pos] = parseFloat(styles[style + '-' + pos + suffix]) || 0;
  }
  result.width = result.left + result.right;
  result.height = result.top + result.bottom;
  return result;
}

const useOffsetPos = (x: number, y: number, target: HTMLElement | EventTarget) =>
  (x > 0 || y > 0) && (!target || !(target as HTMLElement).shadowRoot);

/**
 * @param e
 * @param canvas
 * @returns Canvas position
 */
function getCanvasPosition(
  e: Event | TouchEvent | MouseEvent,
  canvas: HTMLCanvasElement
): {
    x: number;
    y: number;
    box: boolean;
  } {
  const touches = (e as TouchEvent).touches;
  const source = (touches && touches.length ? touches[0] : e) as MouseEvent;
  const {offsetX, offsetY} = source as MouseEvent;
  let box = false;
  let x, y;
  if (useOffsetPos(offsetX, offsetY, e.target)) {
    x = offsetX;
    y = offsetY;
  } else {
    const rect = canvas.getBoundingClientRect();
    x = source.clientX - rect.left;
    y = source.clientY - rect.top;
    box = true;
  }
  return {x, y, box};
}

/**
 * Gets an event's x, y coordinates, relative to the chart area
 * @param event
 * @param chart
 * @returns x and y coordinates of the event
 */

export function getRelativePosition(
  event: Event | ChartEvent | TouchEvent | MouseEvent,
  chart: Chart
): { x: number; y: number } {
  if ('native' in event) {
    return event;
  }

  const {canvas, currentDevicePixelRatio} = chart;
  const style = getComputedStyle(canvas);
  const borderBox = style.boxSizing === 'border-box';
  const paddings = getPositionedStyle(style, 'padding');
  const borders = getPositionedStyle(style, 'border', 'width');
  const {x, y, box} = getCanvasPosition(event, canvas);
  const xOffset = paddings.left + (box && borders.left);
  const yOffset = paddings.top + (box && borders.top);

  let {width, height} = chart;
  if (borderBox) {
    width -= paddings.width + borders.width;
    height -= paddings.height + borders.height;
  }
  return {
    x: Math.round((x - xOffset) / width * canvas.width / currentDevicePixelRatio),
    y: Math.round((y - yOffset) / height * canvas.height / currentDevicePixelRatio)
  };
}

function getContainerSize(canvas: HTMLCanvasElement, width: number, height: number): Partial<Scale> {
  let maxWidth: number, maxHeight: number;

  if (width === undefined || height === undefined) {
    const container = canvas && _getParentNode(canvas);
    if (!container) {
      width = canvas.clientWidth;
      height = canvas.clientHeight;
    } else {
      const rect = container.getBoundingClientRect(); // this is the border box of the container
      const containerStyle = getComputedStyle(container);
      const containerBorder = getPositionedStyle(containerStyle, 'border', 'width');
      const containerPadding = getPositionedStyle(containerStyle, 'padding');
      width = rect.width - containerPadding.width - containerBorder.width;
      height = rect.height - containerPadding.height - containerBorder.height;
      maxWidth = parseMaxStyle(containerStyle.maxWidth, container, 'clientWidth');
      maxHeight = parseMaxStyle(containerStyle.maxHeight, container, 'clientHeight');
    }
  }
  return {
    width,
    height,
    maxWidth: maxWidth || INFINITY,
    maxHeight: maxHeight || INFINITY
  };
}

const round1 = (v: number) => Math.round(v * 10) / 10;

// eslint-disable-next-line complexity
export function getMaximumSize(
  canvas: HTMLCanvasElement,
  bbWidth?: number,
  bbHeight?: number,
  aspectRatio?: number
): { width: number; height: number } {
  const style = getComputedStyle(canvas);
  const margins = getPositionedStyle(style, 'margin');
  const maxWidth = parseMaxStyle(style.maxWidth, canvas, 'clientWidth') || INFINITY;
  const maxHeight = parseMaxStyle(style.maxHeight, canvas, 'clientHeight') || INFINITY;
  const containerSize = getContainerSize(canvas, bbWidth, bbHeight);
  let {width, height} = containerSize;

  if (style.boxSizing === 'content-box') {
    const borders = getPositionedStyle(style, 'border', 'width');
    const paddings = getPositionedStyle(style, 'padding');
    width -= paddings.width + borders.width;
    height -= paddings.height + borders.height;
  }
  width = Math.max(0, width - margins.width);
  height = Math.max(0, aspectRatio ? width / aspectRatio : height - margins.height);
  width = round1(Math.min(width, maxWidth, containerSize.maxWidth));
  height = round1(Math.min(height, maxHeight, containerSize.maxHeight));
  if (width && !height) {
    // https://github.com/chartjs/Chart.js/issues/4659
    // If the canvas has width, but no height, default to aspectRatio of 2 (canvas default)
    height = round1(width / 2);
  }

  const maintainHeight = bbWidth !== undefined || bbHeight !== undefined;

  if (maintainHeight && aspectRatio && containerSize.height && height > containerSize.height) {
    height = containerSize.height;
    width = round1(Math.floor(height * aspectRatio));
  }

  return {width, height};
}

/**
 * @param chart
 * @param forceRatio
 * @param forceStyle
 * @returns True if the canvas context size or transformation has changed.
 */
export function retinaScale(
  chart: Chart,
  forceRatio: number,
  forceStyle?: boolean
): boolean | void {
  const pixelRatio = forceRatio || 1;
  const deviceHeight = Math.floor(chart.height * pixelRatio);
  const deviceWidth = Math.floor(chart.width * pixelRatio);

  chart.height = Math.floor(chart.height);
  chart.width = Math.floor(chart.width);

  const canvas = chart.canvas;

  // If no style has been set on the canvas, the render size is used as display size,
  // making the chart visually bigger, so let's enforce it to the "correct" values.
  // See https://github.com/chartjs/Chart.js/issues/3575
  if (canvas.style && (forceStyle || (!canvas.style.height && !canvas.style.width))) {
    canvas.style.height = `${chart.height}px`;
    canvas.style.width = `${chart.width}px`;
  }

  if (chart.currentDevicePixelRatio !== pixelRatio
      || canvas.height !== deviceHeight
      || canvas.width !== deviceWidth) {
    chart.currentDevicePixelRatio = pixelRatio;
    canvas.height = deviceHeight;
    canvas.width = deviceWidth;
    chart.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
    return true;
  }
  return false;
}

/**
 * Detects support for options object argument in addEventListener.
 * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support
 * @private
 */
export const supportsEventListenerOptions = (function() {
  let passiveSupported = false;
  try {
    const options = {
      get passive() { // This function will be called when the browser attempts to access the passive property.
        passiveSupported = true;
        return false;
      }
    } as EventListenerOptions;

    if (_isDomSupported()) {
      window.addEventListener('test', null, options);
      window.removeEventListener('test', null, options);
    }
  } catch (e) {
    // continue regardless of error
  }
  return passiveSupported;
}());

/**
 * The "used" size is the final value of a dimension property after all calculations have
 * been performed. This method uses the computed style of `element` but returns undefined
 * if the computed style is not expressed in pixels. That can happen in some cases where
 * `element` has a size relative to its parent and this last one is not yet displayed,
 * for example because of `display: none` on a parent node.
 * @see https://developer.mozilla.org/en-US/docs/Web/CSS/used_value
 * @returns Size in pixels or undefined if unknown.
 */

export function readUsedSize(
  element: HTMLElement,
  property: 'width' | 'height'
): number | undefined {
  const value = getStyle(element, property);
  const matches = value && value.match(/^(\d+)(\.\d+)?px$/);
  return matches ? +matches[1] : undefined;
}