@antv/g2
Version: 
the Grammar of Graphics in Javascript
229 lines (207 loc) • 7.27 kB
text/typescript
import { min, max } from 'd3-array';
import { DataComponent as DC } from '../runtime';
import { flow } from './utils/flow';
import { tagCloud } from './utils/d3-cloud';
type Callable<T> = T | ((datum: any, i: number) => T);
/**
 * See the document here: https://github.com/jasondavies/d3-cloud.
 */
export type WordCloudOptions = {
  /**
   * Internally, the layout uses setInterval to avoid locking up the browser’s event loop.
   * If specified, time is the maximum amount of time that can be spent during the current timestep.
   * If not specified, returns the current maximum time interval, which defaults to Infinity.
   */
  timeInterval: number;
  /**
   * If specified, sets the words array. If not specified, returns the current words array, which defaults to [].
   */
  words: any[];
  /**
   * If specified, sets the rectangular [width, height] of the layout.
   * If not specified, returns the current size, which defaults to [1, 1].
   */
  size: [number, number];
  /**
   * If specified, sets the font accessor function, which indicates the font face for each word.
   * If not specified, returns the current font accessor function, which defaults to "serif".
   * A constant may be specified instead of a function.
   */
  font: Callable<CSSStyleDeclaration['fontFamily']>;
  /**
   * TODO: Cname of font.
   */
  fontFamily: Callable<CSSStyleDeclaration['fontFamily']>;
  /**
   * If specified, sets the fontStyle accessor function, which indicates the font style for each word.
   * If not specified, returns the current fontStyle accessor function, which defaults to "normal".
   * A constant may be specified instead of a function.
   */
  fontStyle: Callable<CSSStyleDeclaration['fontFamily']>;
  /**
   * If specified, sets the fontWeight accessor function, which indicates the font weight for each word.
   * If not specified, returns the current fontWeight accessor function, which defaults to "normal".
   * A constant may be specified instead of a function.
   */
  fontWeight: Callable<CSSStyleDeclaration['fontFamily']>;
  /**
   * If specified, sets the fontSize accessor function, which indicates the numerical font size for each word.
   * If not specified, returns the current fontSize accessor function, which defaults to:
   *
   * > function(d) { return Math.sqrt(d.value); }
   *
   * A constant may be specified instead of a function.
   *
   * If the fontSize is an array, it will be normalized to the range of [min, max] of the data value.
   * If the fontSize is a function, it will be called with the data value.
   * If the fontSize is a number, it will be used as the constant value.
   */
  fontSize: number | [number, number] | ((d: any) => number);
  /**
   * If specified, sets the rotate accessor function, which indicates the rotation angle (in degrees) for each word.
   * If not specified, returns the current rotate accessor function, which defaults to:
   *
   * function() { return (~~(Math.random() * 6) - 3) * 30; }
   *
   * A constant may be specified instead of a function.
   */
  rotate: Callable<number>;
  /**
   * If specified, sets the text accessor function, which indicates the text for each word.
   * If not specified, returns the current text accessor function, which defaults to:
   *
   * function(d) { return d.text; }
   *
   * A constant may be specified instead of a function.
   */
  text: Callable<string>;
  /**
   * If specified, sets the padding accessor function, which indicates the numerical padding for each word.
   * If not specified, returns the current padding, which defaults to 1.
   */
  padding: Callable<number>;
  /**
   * If specified, sets the internal random number generator, used for selecting the initial position of each word,
   *  and the clockwise/counterclockwise direction of the spiral for each word.
   * This should return a number in the range [0, 1).
   * If not specified, returns the current random number generator, which defaults to Math.random.
   */
  random: () => number;
  /**
   * If specified, sets the spiral used for positioning words.
   */
  spiral: any;
  /**
   * If specified, sets the image mask used for positioning words.
   */
  canvas: HTMLCanvasElement;
};
const DEFAULT_OPTIONS = {
  fontSize: [20, 60],
  font: 'Impact',
  padding: 2,
  rotate: function () {
    return (~~(Math.random() * 6) - 3) * 30;
  },
};
/**
 * Process the image mask of wordCloud.
 * @param img
 * @returns
 */
export function processImageMask(
  img: HTMLImageElement | string,
): Promise<HTMLImageElement> {
  return new Promise((res, rej) => {
    if (img instanceof HTMLImageElement) {
      res(img);
      return;
    }
    if (typeof img === 'string') {
      const image = new Image();
      image.crossOrigin = 'anonymous';
      image.src = img;
      image.onload = () => res(image);
      image.onerror = () => {
        console.error(`'image ${img} load failed !!!'`);
        rej();
      };
      return;
    }
    rej();
  });
}
/**
 * normalize fontSize range to d3-cloud fontSize function.
 * @param fontSize
 * @param range
 * @returns
 */
export function normalizeFontSize(fontSize: any, range?: [number, number]) {
  if (typeof fontSize === 'function') return fontSize;
  if (Array.isArray(fontSize)) {
    const [fMin, fMax] = fontSize;
    if (!range) return () => (fMax + fMin) / 2;
    const [min, max] = range;
    if (max === min) return () => (fMax + fMin) / 2;
    return ({ value }) => ((fMax - fMin) / (max - min)) * (value - min) + fMin;
  }
  return () => fontSize;
}
export const WordCloud: DC<Partial<WordCloudOptions>> = (options, context) => {
  return async (data) => {
    const cloudOptions = Object.assign({}, DEFAULT_OPTIONS, options, {
      canvas: context.createCanvas,
    });
    const layout = tagCloud();
    await flow(layout, cloudOptions)
      .set('fontSize', (v) => {
        const arr = data.map((d) => d.value);
        return normalizeFontSize(v, [min(arr) as any, max(arr) as any]);
      })
      .set('font')
      .set('fontStyle')
      .set('fontWeight')
      .set('padding')
      .set('rotate')
      .set('size')
      .set('spiral')
      .set('timeInterval')
      .set('random')
      .set('text')
      .set('on')
      .set('canvas')
      .setAsync('imageMask', processImageMask, layout.createMask);
    layout.words([...data]);
    const result = layout.start();
    const [cw, ch] = cloudOptions.size;
    const defaultBounds = [
      { x: 0, y: 0 },
      { x: cw, y: ch },
    ];
    const { _bounds: bounds = defaultBounds, _tags, hasImage } = result;
    const tags = _tags.map(({ x, y, font, ...rest }) => ({
      ...rest,
      x: x + cw / 2,
      y: y + ch / 2,
      fontFamily: font,
    }));
    // Append two data to replace the corner of top-left and bottom-right, avoid calculate the actual bounds will occur some error.
    const [{ x: tlx, y: tly }, { x: brx, y: bry }] = bounds;
    const invisibleText = { text: '', value: 0, opacity: 0, fontSize: 0 };
    tags.push(
      {
        ...invisibleText,
        x: hasImage ? 0 : tlx,
        y: hasImage ? 0 : tly,
      },
      {
        ...invisibleText,
        x: hasImage ? cw : brx,
        y: hasImage ? ch : bry,
      },
    );
    return tags;
  };
};
WordCloud.props = {};