Skip to content

Commit

Permalink
feat: add heatmap mark
Browse files Browse the repository at this point in the history
  • Loading branch information
hustcc committed May 24, 2023
1 parent 8eeed36 commit 8dfcf6a
Show file tree
Hide file tree
Showing 12 changed files with 172 additions and 102 deletions.
Binary file modified __tests__/integration/snapshots/static/HeatmapHeatmapBasic.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 29 additions & 11 deletions __tests__/plots/static/heatmap-heatmap-basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,36 @@ import { G2Spec } from '../../../src';

export function HeatmapHeatmapBasic(): G2Spec {
return {
type: 'heatmap',
type: 'view',
padding: 0,
data: {
type: 'fetch',
value: 'data/heatmap.json',
},
encode: {
x: 'g',
y: 'l',
color: 'tmp',
},
axis: false,
children: [
{
type: 'image',
data: [0],
encode: {
src: 'https://gw.alipayobjects.com/zos/rmsportal/NeUTMwKtPcPxIFNTWZOZ.png',
},
style: {
x: '50%',
y: '50%',
width: '100%',
height: '100%',
},
},
{
type: 'heatmap',
data: {
type: 'fetch',
value: 'data/heatmap.json',
},
encode: {
x: 'g',
y: 'l',
color: 'tmp',
},
axis: false,
},
],
};
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"d3-scale-chromatic": "^3.0.0",
"d3-shape": "^3.1.0",
"d3-voronoi": "^1.1.4",
"flru": "^1.0.2",
"pdfast": "^0.2.0"
},
"devDependencies": {
Expand Down
6 changes: 3 additions & 3 deletions src/mark/heatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ export const Heatmap: MC<HeatmapOptions> = (options) => {
return (index, scale, value, coordinate) => {
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.
// Default size = 40.
const r = S ? +S[i] : 40;
// Warning: x, y, value, radius.
return [...coordinate.map([+X[i], +Y[i]]), C[i], r] as unknown as Vector2;
});

Expand Down
1 change: 0 additions & 1 deletion src/mark/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const Text: MC<TextOptions> = (options) => {
const { cartesian = false } = options;
if (cartesian) return visualMark as Mark;
return ((index, scale, value, coordinate) => {
if (cartesian) return visualMark(index, scale, value, coordinate);
const { x: X, y: Y } = value;
const offset = createBandOffset(scale, value, options);
const P = Array.from(index, (i) => {
Expand Down
2 changes: 1 addition & 1 deletion src/mark/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function createBandOffset(
};
}

function p(d) {
export function p(d) {
return parseFloat(d) / 100;
}

Expand Down
16 changes: 9 additions & 7 deletions src/runtime/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ function Canvas(width: number, height: number): GCanvas {
export function render<T extends G2ViewTree = G2ViewTree>(
options: T,
context: G2Context = {},
resolve?: () => void,
reject?: (e: Error) => void,
resolve = (): void => {},
reject = (e?: any): void => {
throw e;
},
): HTMLElement {
// Initialize the context if it is not provided.
const { width = 640, height = 480, theme } = options;
Expand Down Expand Up @@ -113,8 +115,10 @@ export function render<T extends G2ViewTree = G2ViewTree>(
export function renderToMountedElement<T extends G2ViewTree = G2ViewTree>(
options: T,
context: G2Context = {},
resolve?: () => void,
reject?: (e: Error) => void,
resolve = () => {},
reject = (e?: any) => {
throw e;
},
): DisplayObject {
// Initialize the context if it is not provided.
const { width = 640, height = 480, on } = options;
Expand All @@ -126,9 +130,7 @@ export function renderToMountedElement<T extends G2ViewTree = G2ViewTree>(
} = context;

if (!group?.parentElement) {
throw new Error(
`renderToMountedElement can't render chart to unmounted group.`,
);
error(`renderToMountedElement can't render chart to unmounted group.`);
}

const selection = select(group);
Expand Down
39 changes: 30 additions & 9 deletions src/shape/heatmap/heatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,29 @@ import type { HeatmapRendererOptions } from './renderer/types';

export type HeatmapOptions = HeatmapRendererOptions;

function deleteKey(obj: any, fn: (v, k) => boolean) {
const r = { ...obj };
return Object.keys(obj).reduce((r, k) => {
const v = obj[k];
if (!fn(v, k)) r[k] = v;
return r;
}, {});
}

export const Heatmap: SC<HeatmapOptions> = (options) => {
const { ...style } = options;
const {
gradient,
opacity,
maxOpacity,
minOpacity,
blur,
useGradientOpacity,
...style
} = options;
return (points: number[][], value, coordinate, theme, _, context) => {
const { mark, shape, defaultShape, transform } = value;
const {
defaultColor,
fill = defaultColor,
stroke = defaultColor,
...shapeTheme
} = getShapeTheme(theme, mark, shape, defaultShape);
const { ...shapeTheme } = getShapeTheme(theme, mark, shape, defaultShape);
const { createCanvas } = context;

const [width, height] = coordinate.getSize();
const data = points.map((p: number[]) => ({
Expand All @@ -26,17 +39,25 @@ export const Heatmap: SC<HeatmapOptions> = (options) => {
value: p[2],
radius: p[3],
}));

const min = d3min(points, (p) => p[2]);
const max = d3max(points, (p) => p[2]);

const { createCanvas } = context;
const options = {
gradient,
opacity,
minOpacity,
maxOpacity,
blur,
useGradientOpacity,
};
const ctx = HeatmapRenderer(
width,
height,
min,
max,
data,
{ ...style },
deleteKey(options, (v) => v === undefined),
createCanvas,
);

Expand Down
122 changes: 64 additions & 58 deletions src/shape/heatmap/renderer/index.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,67 @@
import { lru } from '../../../utils/lru';
import { HeatmapRendererData, HeatmapRendererOptions } from './types';

function newCanvas(
createCanvas: () => HTMLCanvasElement,
width: number,
height: number,
) {
const c = createCanvas ? createCanvas() : document.createElement('canvas');
c.width = width;
c.height = height;
return c;
}

/**
* Get a point with template.
* @param radius
* @param blurFactor
* @returns
*/
function getPointTemplate(
radius: number,
blurFactor: number,
createCanvas?: () => HTMLCanvasElement,
) {
const tplCanvas = createCanvas
? createCanvas()
: 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;
}
const getPointTemplate = lru(
(
radius: number,
blurFactor: number,
createCanvas?: () => HTMLCanvasElement,
) => {
const tplCanvas = newCanvas(createCanvas, radius * 2, radius * 2);
const tplCtx = tplCanvas.getContext('2d');
const x = radius;
const y = radius;

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;
},
(radius) => `${radius}`,
);

/**
* Get a color palette with len = 256 base on gradient.
* @param gradientConfig
* @returns
*/
function getColorPalette(gradientConfig: any) {
const paletteCanvas = document.createElement('canvas');
function getColorPalette(gradientConfig: any, createCanvas) {
const paletteCanvas = newCanvas(createCanvas, 256, 1);
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]);
Expand Down Expand Up @@ -85,7 +94,7 @@ function drawAlpha(
const rectY = y - radius;

// TODO: cache for performance.
const tpl = getPointTemplate(radius, blur, createCanvas);
const tpl = getPointTemplate(radius, 1 - blur, createCanvas);
// 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.
Expand All @@ -102,7 +111,7 @@ function colorize(
palette,
options: HeatmapRendererOptions,
) {
const { maxOpacity, minOpacity, useGradientOpacity } = options;
const { minOpacity, opacity, maxOpacity, useGradientOpacity } = options;
const x = 0;
const y = 0;
const width = maxWidth;
Expand All @@ -121,10 +130,8 @@ function colorize(
}

// Should be in [min, max], min >= 0.
const finalAlpha = Math.max(
0,
Math.min(maxOpacity, Math.max(minOpacity, alpha)),
);
const finalAlpha =
opacity || Math.max(0, Math.min(maxOpacity, Math.max(minOpacity, alpha)));
// Update rgba.
imgData[i - 3] = palette[offset];
imgData[i - 2] = palette[offset + 1];
Expand All @@ -149,34 +156,33 @@ export function HeatmapRenderer(
createCanvas?: () => HTMLCanvasElement,
) {
const opts = {
blur: 0.5,
blur: 0.85,
minOpacity: 0,
opacity: 0.6,
maxOpacity: 1,
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,
};
opts.minOpacity *= 255;
opts.opacity *= 255;
opts.maxOpacity *= 255;

const canvas = createCanvas
? createCanvas()
: document.createElement('canvas');
const shadowCanvas = createCanvas
? createCanvas()
: document.createElement('canvas');

const ctx = canvas.getContext('2d');
const shadowCanvas = newCanvas(createCanvas, width, height);
const shadowCtx = shadowCanvas.getContext('2d');

const palette = getColorPalette(opts.gradient);
const palette = getColorPalette(opts.gradient, createCanvas);

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

const canvas = newCanvas(createCanvas, width, height);
const ctx = canvas.getContext('2d');
ctx.putImageData(img, 0, 0);

return ctx;
Expand Down
10 changes: 1 addition & 9 deletions src/shape/heatmap/renderer/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
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!
Expand All @@ -25,7 +17,7 @@ export type HeatmapRendererOptions = {
*/
minOpacity?: number;
/**
* The blur factor that will be applied to all datapoints, default = 0.5.
* The blur factor that will be applied to all datapoints, default = 0.85.
* The higher the blur factor is, the smoother the gradients will be.
*/
blur?: number;
Expand Down
Loading

0 comments on commit 8dfcf6a

Please sign in to comment.