@antv/g2
Version: 
the Grammar of Graphics in Javascript
366 lines (347 loc) • 11.1 kB
text/typescript
import { deepMix } from '@antv/util';
import { extent, group, max } from 'd3-array';
import {
  CompositionComponent as CC,
  G2MarkChildrenCallback,
  G2ViewTree,
  Node,
} from '../runtime';
import { FacetRectComposition } from '../spec';
import { calcBBox } from '../utils/vector';
import { Container } from '../utils/container';
import { indexOf } from '../utils/array';
import { useDefaultAdaptor, useOverrideAdaptor } from './utils';
export type SubLayout = (data?: any) => number[];
const setScale = useDefaultAdaptor<G2ViewTree>((options) => {
  const { encode, data, scale, shareSize = false } = options;
  const { x, y } = encode;
  const flexDomain = (encode: string, channel: string) => {
    if (encode === undefined || !shareSize) return {};
    const groups = group(data, (d) => d[encode]);
    const domain = scale?.[channel]?.domain || Array.from(groups.keys());
    const flex = domain.map((key) => {
      if (!groups.has(key)) return 1;
      return groups.get(key).length;
    });
    return { domain, flex };
  };
  return {
    scale: {
      x: {
        paddingOuter: 0,
        paddingInner: 0.1,
        guide: x === undefined ? null : { position: 'top' },
        ...(x === undefined && { paddingInner: 0 }),
        ...flexDomain(x, 'x'),
      },
      y: {
        range: [0, 1],
        paddingOuter: 0,
        paddingInner: 0.1,
        guide: y === undefined ? null : { position: 'right' },
        ...(y === undefined && { paddingInner: 0 }),
        ...flexDomain(y, 'y'),
      },
    },
  };
});
/**
 * BFS view tree and using the last discovered color encode
 * as the top-level encode for this plot. This is useful when
 * color encode and color scale is specified in mark node.
 * It makes sense because the whole facet should shared the same
 * color encoding, but it also can be override with explicity
 * encode and scale specification.
 */
export const inferColor = useDefaultAdaptor<G2ViewTree>(
  (options: G2ViewTree) => {
    const { data, scale } = options;
    const discovered = [options];
    let encodeColor;
    let scaleColor;
    let legendColor;
    while (discovered.length) {
      const node = discovered.shift();
      const { children, encode = {}, scale = {}, legend = {} } = node;
      const { color: c } = encode;
      const { color: cs } = scale;
      const { color: cl } = legend;
      if (c !== undefined) encodeColor = c;
      if (cs !== undefined) scaleColor = cs;
      if (cl !== undefined) legendColor = cl;
      if (Array.isArray(children)) {
        discovered.push(...children);
      }
    }
    const domainColor = () => {
      const domain = scale?.color?.domain;
      if (domain !== undefined) return [domain];
      if (encodeColor === undefined) return [undefined];
      const color =
        typeof encodeColor === 'function' ? encodeColor : (d) => d[encodeColor];
      const values = data.map(color);
      if (values.some((d) => typeof d === 'number')) return [extent(values)];
      return [Array.from(new Set(values)), 'ordinal'];
    };
    const title = typeof encodeColor === 'string' ? encodeColor : '';
    const [domain, type] = domainColor();
    return {
      encode: { color: encodeColor },
      scale: { color: deepMix({}, scaleColor, { domain, type }) },
      legend: { color: deepMix({ title }, legendColor) },
    };
  },
);
export const setAnimation = useDefaultAdaptor<G2ViewTree>(() => ({
  animate: {
    enterType: 'fadeIn',
  },
}));
export const setStyle = useOverrideAdaptor<G2ViewTree>(() => ({
  frame: false,
  encode: {
    shape: 'hollow',
  },
  style: {
    lineWidth: 0,
  },
}));
export const toCell = useOverrideAdaptor<G2ViewTree>(() => ({
  type: 'cell',
}));
/**
 * Do not set cell data directly, the children will get wrong do if do
 * so. Use transform to set new data.
 **/
export const setData = useOverrideAdaptor<G2ViewTree>((options) => {
  const { data } = options;
  const connector = {
    type: 'custom',
    callback: () => {
      const { data, encode } = options;
      const { x, y } = encode;
      const X = x ? Array.from(new Set(data.map((d) => d[x]))) : [];
      const Y = y ? Array.from(new Set(data.map((d) => d[y]))) : [];
      const cellData = () => {
        if (X.length && Y.length) {
          const cellData = [];
          for (const vx of X) {
            for (const vy of Y) {
              cellData.push({ [x]: vx, [y]: vy });
            }
          }
          return cellData;
        }
        if (X.length) return X.map((d) => ({ [x]: d }));
        if (Y.length) return Y.map((d) => ({ [y]: d }));
      };
      return cellData();
    },
  };
  return {
    data: { type: 'inline', value: data, transform: [connector] },
  };
});
/**
 * @todo Move some options assignment to runtime.
 */
export const setChildren = useOverrideAdaptor<G2ViewTree>(
  (
    options,
    subLayout: SubLayout = subLayoutRect,
    createGuideX = createGuideXRect,
    createGuideY = createGuideYRect,
    childOptions = {},
  ) => {
    const {
      data: dataValue,
      encode,
      children,
      scale: facetScale,
      x: originX = 0,
      y: originY = 0,
      shareData = false,
      key: viewKey,
    } = options;
    const { value: data } = dataValue;
    // Only support field encode now.
    const { x: encodeX, y: encodeY } = encode;
    const { color: facetScaleColor } = facetScale;
    const { domain: facetDomainColor } = facetScaleColor;
    const createChildren: G2MarkChildrenCallback = (
      visualData,
      scale,
      layout,
    ) => {
      const { x: scaleX, y: scaleY } = scale;
      const { paddingLeft, paddingTop, marginLeft, marginTop } = layout;
      const { domain: domainX } = scaleX.getOptions();
      const { domain: domainY } = scaleY.getOptions();
      const index = indexOf(visualData);
      const bboxs = visualData.map(subLayout);
      const values = visualData.map(({ x, y }) => [
        scaleX.invert(x),
        scaleY.invert(y),
      ]);
      const filters = values.map(([fx, fy]) => (d) => {
        const { [encodeX]: x, [encodeY]: y } = d;
        const inX = encodeX !== undefined ? x === fx : true;
        const inY = encodeY !== undefined ? y === fy : true;
        return inX && inY;
      });
      const facetData2d = filters.map((f) => data.filter(f));
      const maxDataDomain = shareData
        ? max(facetData2d, (data) => data.length)
        : undefined;
      const facets = values.map(([fx, fy]) => ({
        columnField: encodeX,
        columnIndex: domainX.indexOf(fx),
        columnValue: fx,
        columnValuesLength: domainX.length,
        rowField: encodeY,
        rowIndex: domainY.indexOf(fy),
        rowValue: fy,
        rowValuesLength: domainY.length,
      }));
      const normalizedChildren: Node[][] = facets.map((facet) => {
        if (Array.isArray(children)) return children;
        return [children(facet)].flat(1);
      });
      return index.flatMap((i) => {
        const [left, top, width, height] = bboxs[i];
        const facet = facets[i];
        const facetData = facetData2d[i];
        const children = normalizedChildren[i];
        return children.map(
          ({
            scale,
            key,
            facet: isFacet = true,
            axis = {},
            legend = {},
            ...rest
          }) => {
            const guideY = scale?.y?.guide || axis.y;
            const guideX = scale?.x?.guide || axis.x;
            const defaultScale = {
              x: { tickCount: encodeX ? 5 : undefined },
              y: { tickCount: encodeY ? 5 : undefined },
            };
            const newData = isFacet
              ? facetData
              : facetData.length === 0
              ? []
              : data;
            const newScale = {
              color: { domain: facetDomainColor },
            };
            const newAxis = {
              x: createGuide(guideX, createGuideX)(facet, newData),
              y: createGuide(guideY, createGuideY)(facet, newData),
            };
            return {
              key: `${key}-${i}`,
              data: newData,
              margin: 0,
              x: left + paddingLeft + originX + marginLeft,
              y: top + paddingTop + originY + marginTop,
              parentKey: viewKey,
              width,
              height,
              paddingLeft: 0,
              paddingRight: 0,
              paddingTop: 0,
              paddingBottom: 0,
              frame: newData.length ? true : false,
              dataDomain: maxDataDomain,
              scale: deepMix(defaultScale, scale, newScale),
              axis: deepMix({}, axis, newAxis),
              // Hide all legends for child mark by default,
              // they are displayed in the top-level.
              legend: false,
              ...rest,
              ...childOptions,
            };
          },
        );
      });
    };
    return {
      children: createChildren,
    };
  },
);
function subLayoutRect(data) {
  const { points } = data;
  return calcBBox(points);
}
/**
 * Inner guide not show title, tickLine, label and subTickLine,
 * if data is empty, do not show guide.
 */
export function createInnerGuide(guide, data) {
  return data.length
    ? deepMix(
        {
          title: false,
          tick: null,
          label: null,
        },
        guide,
      )
    : deepMix(
        {
          title: false,
          tick: null,
          label: null,
          grid: null,
        },
        guide,
      );
}
function createGuideXRect(guide) {
  return (facet, data) => {
    const { rowIndex, rowValuesLength, columnIndex, columnValuesLength } =
      facet;
    // Only the bottom-most facet show axisX.
    if (rowIndex !== rowValuesLength - 1) return createInnerGuide(guide, data);
    // Only the bottom-left facet show title.
    const title = columnIndex !== columnValuesLength - 1 ? false : undefined;
    // If data is empty, do not show cell.
    const grid = data.length ? undefined : null;
    return deepMix({ title, grid }, guide);
  };
}
function createGuideYRect(guide) {
  return (facet, data) => {
    const { rowIndex, columnIndex } = facet;
    // Only the left-most facet show axisY.
    if (columnIndex !== 0) return createInnerGuide(guide, data);
    // Only the left-top facet show title.
    const title = rowIndex !== 0 ? false : undefined;
    // If data is empty, do not show cell.
    const grid = data.length ? undefined : null;
    return deepMix({ title, grid }, guide);
  };
}
function createGuide(guide, factory) {
  if (typeof guide === 'function') return guide;
  if (guide === null || guide === false) return () => null;
  return factory(guide);
}
export type FacetRectOptions = Omit<FacetRectComposition, 'type'>;
export const FacetRect: CC<FacetRectOptions> = () => {
  return (options) => {
    const newOptions = Container.of<G2ViewTree>(options)
      .call(toCell)
      .call(inferColor)
      .call(setAnimation)
      .call(setScale)
      .call(setStyle)
      .call(setData)
      .call(setChildren)
      .value();
    return [newOptions];
  };
};
FacetRect.props = {};