Skip to content

Commit

Permalink
feat: add heatmap canvas readerer
Browse files Browse the repository at this point in the history
  • Loading branch information
hustcc committed May 18, 2023
1 parent 97d03d1 commit ad5dc56
Show file tree
Hide file tree
Showing 8 changed files with 2,771 additions and 36 deletions.
2,502 changes: 2,502 additions & 0 deletions __tests__/data/heatmap.json

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions __tests__/plots/static/heatmap-heatmap-basic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { G2Spec } from '../../../src';

export function HeatmapHeatmapBasic(): G2Spec {
return {
type: 'heatmap',
padding: 0,
data: {
type: 'fetch',
value: 'data/heatmap.json',
},
encode: {
x: 'g',
y: 'l',
color: 'tmp',
},
axis: false,
};
}

HeatmapHeatmapBasic.maxError = 100;
1 change: 1 addition & 0 deletions __tests__/plots/static/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,4 @@ export { aaplLineBasicTranspose } from './aapl-line-basic-transpose';
export { alphabetIntervalSortXDomain } from './alphabet-interval-sort-x-domain';
export { basicIntervalZeroDomainMin } from './basic-interval-zero-domain-min';
export { aaplLineAxisYHide } from './aapl-line-axis-y-hide';
export { HeatmapHeatmapBasic } from './heatmap-heatmap-basic';
43 changes: 11 additions & 32 deletions src/mark/heatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
baseGeometryChannels,
basePostInference,
basePreInference,
createBandOffset,
tooltip2d,
} from './utils';

Expand All @@ -15,32 +14,15 @@ export type HeatmapOptions = Omit<HeatmapMark, 'type'>;
*/
export const Heatmap: MC<HeatmapOptions> = (options) => {
return (index, scale, value, coordinate) => {
const { x: X, y: Y, x1: X1, y1: Y1, size: S } = value;
const [width, height] = coordinate.getSize();
const offset = createBandOffset(scale, value, options);
const xy: (i: number) => Vector2 = (i) => {
const x = X1 ? (+X[i] + +X1[i]) / 2 : +X[i];
const y = Y1 ? (+Y[i] + +Y1[i]) / 2 : +Y[i];
return [x, y];
};
const P = S
? Array.from(index, (i) => {
const [cx, cy] = xy(i);
const r = +S[i];
const a = r / width;
const b = r / height;
const p1: Vector2 = [cx - a, cy - b];
const p2: Vector2 = [cx + a, cy + b];
return [
coordinate.map(offset(p1, i)),
coordinate.map(offset(p2, i)),
] as Vector2[];
})
: Array.from(
index,
(i) => [coordinate.map(offset(xy(i), i))] as Vector2[],
);
return [index, P];
const { x: X, y: Y, size: S, color: C } = value;
const P = Array.from(index, (i) => {
// Default size = 20.
const r = S ? +S[i] : 20;
//Warning: x, y, value, radius.
return [...coordinate.map([+X[i], +Y[i]]), C[i], r] as unknown as Vector2;
});

return [[0], [P]];
};
};

Expand All @@ -54,16 +36,13 @@ Heatmap.props = {
...baseGeometryChannels({ shapes }),
{ name: 'x', required: true },
{ name: 'y', required: true },
{ name: 'color', scale: 'identity', required: true },
{ name: 'size' },
],
preInference: [
...basePreInference(),
{ type: 'maybeZeroY' },
{ type: 'maybeZeroX' },
],
postInference: [
...basePostInference(),
{ type: 'maybeSize' },
...tooltip2d(),
],
postInference: [...basePostInference(), ...tooltip2d()],
};
27 changes: 23 additions & 4 deletions src/shape/heatmap/heatmap.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { max as d3max, min as d3min } from 'd3-array';
import { Image as GImage } from '@antv/g';
import { applyStyle, getShapeTheme } from '../utils';
import { select } from '../../utils/selection';
import { ShapeComponent as SC, Vector2 } from '../../runtime';
import { HeatmapRenderer } from './renderer';
import type { HeatmapRendererOptions } from './renderer/types';

export type HeatmapOptions = Record<string, any>;
export type HeatmapOptions = HeatmapRendererOptions;

export const Heatmap: SC<HeatmapOptions> = (options) => {
const { ...style } = options;
return (points, value, coordinate, theme) => {
return (points: number[][], value, coordinate, theme) => {
const { mark, shape, defaultShape, color, transform } = value;
const {
defaultColor,
Expand All @@ -16,16 +19,32 @@ export const Heatmap: SC<HeatmapOptions> = (options) => {
...shapeTheme
} = getShapeTheme(theme, mark, shape, defaultShape);

const [width, height] = coordinate.getSize();
const data = points.map((p: number[]) => ({
x: p[0],
y: p[1],
value: p[2],
radius: p[3],
}));
const min = d3min(points, (p) => p[2]);
const max = d3max(points, (p) => p[2]);

const ctx = HeatmapRenderer(width, height, min, max, data, { ...style });

return select(new GImage())
.call(applyStyle, shapeTheme)
.style('src', '')
.style('x', 0)
.style('y', 0)
.style('width', width)
.style('height', height)
.style('src', ctx.canvas)
.style('transform', transform)
.call(applyStyle, style)
.node();
};
};

Box.props = {
Heatmap.props = {
defaultMarker: 'point',
defaultEnterAnimation: 'fadeIn',
defaultUpdateAnimation: 'morphing',
Expand Down
171 changes: 171 additions & 0 deletions src/shape/heatmap/renderer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { HeatmapRendererData, HeatmapRendererOptions } from './types';

/**
* Get a point with template.
* @param radius
* @param blurFactor
* @returns
*/
function getPointTemplate(radius: number, blurFactor: number) {
const tplCanvas = document.createElement('canvas');
const tplCtx = tplCanvas.getContext('2d');
const x = radius;
const y = radius;
tplCanvas.width = tplCanvas.height = radius * 2;

if (blurFactor === 1) {
tplCtx.beginPath();
tplCtx.arc(x, y, radius, 0, 2 * Math.PI, false);
tplCtx.fillStyle = 'rgba(0,0,0,1)';
tplCtx.fill();
} else {
const gradient = tplCtx.createRadialGradient(
x,
y,
radius * blurFactor,
x,
y,
radius,
);
gradient.addColorStop(0, 'rgba(0,0,0,1)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
tplCtx.fillStyle = gradient;
tplCtx.fillRect(0, 0, 2 * radius, 2 * radius);
}
return tplCanvas;
}

/**
* Get a color palette with len = 256 base on gradient.
* @param gradientConfig
* @returns
*/
function getColorPalette(gradientConfig: any) {
const paletteCanvas = document.createElement('canvas');
const paletteCtx = paletteCanvas.getContext('2d');

paletteCanvas.width = 256;
paletteCanvas.height = 1;

const gradient = paletteCtx.createLinearGradient(0, 0, 256, 1);
for (const key in gradientConfig) {
gradient.addColorStop(+key, gradientConfig[key]);
}

paletteCtx.fillStyle = gradient;
paletteCtx.fillRect(0, 0, 256, 1);

return paletteCtx.getImageData(0, 0, 256, 1).data;
}

/**
* Draw all circle with alpha.
*/
function drawAlpha(
shadowCtx,
min: number,
max: number,
data: HeatmapRendererData[],
options: HeatmapRendererOptions,
) {
const { blur } = options;
let len = data.length;
while (len--) {
const { x, y, value: v, radius } = data[len];
// Ff value is bigger than max, use max as value.
const value = Math.min(v, max);
const rectX = x - radius;
const rectY = y - radius;

// TODO: cache for performance.
const tpl = getPointTemplate(radius, blur);
// Value from minimum / value range, => [0, 1].
const templateAlpha = (value - min) / (max - min);
// Small values are not visible because globalAlpha < .01 cannot be read from imageData.
shadowCtx.globalAlpha = Math.max(templateAlpha, 0.01);
shadowCtx.drawImage(tpl, rectX, rectY);
}
return shadowCtx;
}

function colorize(
shadowCtx,
maxWidth: number,
maxHeight: number,
palette,
options: HeatmapRendererOptions,
) {
const { maxOpacity, minOpacity, useGradientOpacity } = options;
const x = 0;
const y = 0;
const width = maxWidth;
const height = maxHeight;

const img = shadowCtx.getImageData(x, y, width, height);
const imgData = img.data;
const len = imgData.length;

for (let i = 3; i < len; i += 4) {
const alpha = imgData[i];
const offset = alpha * 4;

if (!offset) {
continue;
}

// Should be in [min, max], min >= 0.
const finalAlpha = Math.max(
0,
Math.min(maxOpacity, Math.max(minOpacity, alpha)),
);
// Update rgba.
imgData[i - 3] = palette[offset];
imgData[i - 2] = palette[offset + 1];
imgData[i - 1] = palette[offset + 2];
imgData[i] = useGradientOpacity ? palette[offset + 3] : finalAlpha;
}

return img;
}

/**
* Render a heatmap with canvas.
* See [heatmap.js](https://github.com/pa7/heatmap.js/blob/master/src/renderer/canvas2d.js).
*/
export function HeatmapRenderer(
width: number,
height: number,
min: number,
max: number,
data: HeatmapRendererData[],
options: HeatmapRendererOptions,
) {
const opts = {
blur: 0.5,
gradient: {
0.25: 'rgb(0,0,255)',
0.55: 'rgb(0,255,0)',
0.85: 'yellow',
1.0: 'rgb(255,0,0)',
},
...options,
opacity: (options.opacity || 0.65) * 255,
maxOpacity: (options.opacity || 1) * 255,
minOpacity: (options.opacity || 0) * 255,
};

const canvas = document.createElement('canvas');
const shadowCanvas = document.createElement('canvas');

const ctx = canvas.getContext('2d');
const shadowCtx = shadowCanvas.getContext('2d');

const palette = getColorPalette(opts.gradient);

drawAlpha(shadowCtx, min, max, data, opts);
const img = colorize(shadowCtx, width, height, palette, opts);

ctx.putImageData(img, 0, 0);

return ctx;
}
43 changes: 43 additions & 0 deletions src/shape/heatmap/renderer/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export type HeatmapRendererOptions = {
/**
* A background color string in form of hexcode, color name, or rgb(a).
*/
backgroundColor?: string;
/**
* An gradient string that represents the gradient (syntax: number string [0,1] : color string).
*/
gradient?: Record<string, string>;
/**
* The radius each datapoint will have (if not specified on the datapoint itself).
*/
radius?: number;
/**
* A global opacity for the whole heatmap, default = 0.6.
* This overrides maxOpacity and minOpacity if set!
*/
opacity?: number;
/**
* The maximal opacity the highest value in the heatmap will have. (will be overridden if opacity set).
*/
maxOpacity?: number;
/**
* The minimum opacity the lowest value in the heatmap will have (will be overridden if opacity set).
*/
minOpacity?: number;
/**
* The blur factor that will be applied to all datapoints, default = 0.5.
* The higher the blur factor is, the smoother the gradients will be.
*/
blur?: number;
/**
* Use gradient opacity.
*/
useGradientOpacity?: boolean;
};

export type HeatmapRendererData = {
x: number;
y: number;
value: number;
radius: number;
};

0 comments on commit ad5dc56

Please sign in to comment.