diff --git a/__tests__/integration/snapshots/static/HeatmapHeatmapBasic.png b/__tests__/integration/snapshots/static/HeatmapHeatmapBasic.png index 48506c5d21..3f429498f5 100644 Binary files a/__tests__/integration/snapshots/static/HeatmapHeatmapBasic.png and b/__tests__/integration/snapshots/static/HeatmapHeatmapBasic.png differ diff --git a/__tests__/plots/static/heatmap-heatmap-basic.ts b/__tests__/plots/static/heatmap-heatmap-basic.ts index 70530d1c34..9471e22d88 100644 --- a/__tests__/plots/static/heatmap-heatmap-basic.ts +++ b/__tests__/plots/static/heatmap-heatmap-basic.ts @@ -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, + }, + ], }; } diff --git a/package.json b/package.json index 1b3f912a07..049e39916e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/mark/heatmap.ts b/src/mark/heatmap.ts index d79003f728..5f693e3ed1 100644 --- a/src/mark/heatmap.ts +++ b/src/mark/heatmap.ts @@ -16,9 +16,9 @@ export const Heatmap: MC = (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; }); diff --git a/src/mark/text.ts b/src/mark/text.ts index eac4384991..94bbf393f7 100644 --- a/src/mark/text.ts +++ b/src/mark/text.ts @@ -15,7 +15,6 @@ export const Text: MC = (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) => { diff --git a/src/mark/utils.ts b/src/mark/utils.ts index 6fa866eb6a..7918b8b41d 100644 --- a/src/mark/utils.ts +++ b/src/mark/utils.ts @@ -96,7 +96,7 @@ export function createBandOffset( }; } -function p(d) { +export function p(d) { return parseFloat(d) / 100; } diff --git a/src/runtime/render.ts b/src/runtime/render.ts index 8922d4aa22..24e457b2a6 100644 --- a/src/runtime/render.ts +++ b/src/runtime/render.ts @@ -65,8 +65,10 @@ function Canvas(width: number, height: number): GCanvas { export function render( 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; @@ -113,8 +115,10 @@ export function render( export function renderToMountedElement( 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; @@ -126,9 +130,7 @@ export function renderToMountedElement( } = 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); diff --git a/src/shape/heatmap/heatmap.ts b/src/shape/heatmap/heatmap.ts index 25555a585c..d215e22c25 100644 --- a/src/shape/heatmap/heatmap.ts +++ b/src/shape/heatmap/heatmap.ts @@ -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 = (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[]) => ({ @@ -26,17 +39,25 @@ export const Heatmap: SC = (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, ); diff --git a/src/shape/heatmap/renderer/index.ts b/src/shape/heatmap/renderer/index.ts index b0420c700a..ba72f306af 100644 --- a/src/shape/heatmap/renderer/index.ts +++ b/src/shape/heatmap/renderer/index.ts @@ -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]); @@ -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. @@ -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; @@ -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]; @@ -149,7 +156,10 @@ 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)', @@ -157,26 +167,22 @@ export function HeatmapRenderer( 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; diff --git a/src/shape/heatmap/renderer/types.ts b/src/shape/heatmap/renderer/types.ts index 413694005f..6bf00fa516 100644 --- a/src/shape/heatmap/renderer/types.ts +++ b/src/shape/heatmap/renderer/types.ts @@ -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; - /** - * 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! @@ -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; diff --git a/src/shape/image/image.ts b/src/shape/image/image.ts index be233fbb57..73e278e218 100644 --- a/src/shape/image/image.ts +++ b/src/shape/image/image.ts @@ -2,6 +2,7 @@ import { Image as GImage } from '@antv/g'; import { ShapeComponent as SC } from '../../runtime'; import { applyStyle, getShapeTheme } from '../utils'; import { select } from '../../utils/selection'; +import { p } from '../../mark/utils'; export type ImageOptions = Record; @@ -16,9 +17,14 @@ export const Image: SC = (options) => { defaultShape, ); const { color = defaultColor, src = '', size = 32, transform = '' } = value; - const { width = size, height = size } = style; + let { width = size, height = size } = style; const [[x0, y0]] = points; + // Support percentage width, height. + const [w, h] = coordinate.getSize(); + width = typeof width === 'string' ? p(width) * w : width; + height = typeof height === 'string' ? p(height) * h : height; + const x = x0 - Number(width) / 2; const y = y0 - Number(height) / 2; @@ -26,12 +32,12 @@ export const Image: SC = (options) => { .call(applyStyle, shapeTheme) .style('x', x) .style('y', y) - .style('width', size) - .style('height', size) .style('img', src) .style('stroke', color) .style('transform', transform) .call(applyStyle, style) + .style('width', width) + .style('height', height) .node(); }; }; diff --git a/src/utils/lru.ts b/src/utils/lru.ts new file mode 100644 index 0000000000..fc018bf1d6 --- /dev/null +++ b/src/utils/lru.ts @@ -0,0 +1,25 @@ +import flru from 'flru'; + +const cache = flru(3); +/** + * A decorator to return new function with LRU cache. + */ +export function lru( + fn: (...args: T[]) => V, + keyFn: (...args: T[]) => string = (...args) => `${args[0]}`, + maxSize = 16, +) { + const cache = flru(maxSize); + + return (...args) => { + const key = keyFn(...args); + let v = cache.get(key); + + if (cache.has(key)) return cache.get(key); + + v = fn(...args); + cache.set(key, v); + + return v; + }; +}