From e3e8f00271ccea89b5cd5802ba80e4298a82e455 Mon Sep 17 00:00:00 2001 From: plouc Date: Sun, 9 Jan 2022 21:25:56 +0900 Subject: [PATCH] feat(legends): add support for canvas continuous color legends and add it to heatmap --- .../colors/src/scales/continuousColorScale.ts | 8 +- packages/heatmap/src/HeatMapCanvas.tsx | 20 ++- packages/legends/src/canvas.ts | 143 +++++++++++++++++- packages/legends/src/compute.ts | 38 ++++- .../src/svg/ContinuousColorsLegendSvg.tsx | 51 ++++--- packages/legends/src/types.ts | 1 - website/src/pages/heatmap/canvas.tsx | 23 ++- website/src/pages/heatmap/index.tsx | 1 - 8 files changed, 232 insertions(+), 53 deletions(-) diff --git a/packages/colors/src/scales/continuousColorScale.ts b/packages/colors/src/scales/continuousColorScale.ts index 51e69bb49e..979f33f808 100644 --- a/packages/colors/src/scales/continuousColorScale.ts +++ b/packages/colors/src/scales/continuousColorScale.ts @@ -72,7 +72,7 @@ export const computeContinuousColorScaleColorStops = ( if ('thresholds' in scale) { const stops: { key: string - offset: string + offset: number stopColor: string }[] = [] @@ -82,12 +82,12 @@ export const computeContinuousColorScaleColorStops = ( stops.push({ key: `${index}.0`, - offset: `${Math.round(normalizedScale(start) * 100)}%`, + offset: normalizedScale(start), stopColor: color, }) stops.push({ key: `${index}.1`, - offset: `${Math.round(normalizedScale(end) * 100)}%`, + offset: normalizedScale(end), stopColor: color, }) }) @@ -106,7 +106,7 @@ export const computeContinuousColorScaleColorStops = ( return ((colorStopsScale as any).ticks(steps) as number[]).map((value: number) => ({ key: `${value}`, - offset: `${Math.round(value * 100)}%`, + offset: value, stopColor: `${colorStopsScale(value)}`, })) } diff --git a/packages/heatmap/src/HeatMapCanvas.tsx b/packages/heatmap/src/HeatMapCanvas.tsx index aefd92a6b9..3378c4cbf9 100644 --- a/packages/heatmap/src/HeatMapCanvas.tsx +++ b/packages/heatmap/src/HeatMapCanvas.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, createElement } from 'react' import { getRelativeCursor, isCursorInRect, useDimensions, useTheme, Container } from '@nivo/core' import { renderAxesToCanvas, renderGridLinesToCanvas } from '@nivo/axes' import { useTooltip } from '@nivo/tooltip' +import { renderContinuousColorLegendToCanvas } from '@nivo/legends' import { useHeatMap } from './hooks' import { renderRect, renderCircle } from './canvas' import { canvasDefaultProps } from './defaults' @@ -51,7 +52,7 @@ const InnerHeatMapCanvas = ['labelTextColor'], colors = canvasDefaultProps.colors as HeatMapCommonProps['colors'], emptyColor = canvasDefaultProps.emptyColor, - // legends = canvasDefaultProps.legends, + legends = canvasDefaultProps.legends, // annotations = canvasDefaultProps.annotations as HeatMapCommonProps['annotations'], isInteractive = canvasDefaultProps.isInteractive, // onMouseEnter, @@ -74,7 +75,10 @@ const InnerHeatMapCanvas = ({ + const { xScale, yScale, cells, activeCell, setActiveCell, colorScale } = useHeatMap< + Datum, + ExtraProps + >({ data, valueFormat, width: innerWidth, @@ -160,6 +164,16 @@ const InnerHeatMapCanvas = { renderCell(ctx, { cell, enableLabels, theme }) }) + } else if (layer === 'legends' && colorScale !== null) { + legends.forEach(legend => { + renderContinuousColorLegendToCanvas(ctx, { + ...legend, + containerWidth: innerWidth, + containerHeight: innerHeight, + scale: colorScale, + theme, + }) + }) } }) }, [ @@ -182,6 +196,8 @@ const InnerHeatMapCanvas = { + const { + width, + height, + gradientX1, + gradientY1, + gradientX2, + gradientY2, + colorStops, + ticks, + titleText, + titleX, + titleY, + titleRotation, + titleVerticalAlign, + titleHorizontalAlign, + } = computeContinuousColorsLegend({ + scale, + ticks: _ticks, + length, + thickness, + direction, + tickPosition, + tickSize, + tickSpacing, + tickOverlap, + tickFormat, + title, + titleAlign, + titleOffset, + }) + + const { x, y } = computePositionFromAnchor({ + anchor, + translateX, + translateY, + containerWidth, + containerHeight, + width, + height, + }) + + ctx.save() + ctx.translate(x, y) + + const gradient = ctx.createLinearGradient( + gradientX1 * width, + gradientY1 * height, + gradientX2 * width, + gradientY2 * height + ) + colorStops.forEach(colorStop => { + gradient.addColorStop(colorStop.offset, colorStop.stopColor) + }) + + ctx.fillStyle = gradient + ctx.fillRect(0, 0, width, height) + + ctx.font = `${ + theme.legends.ticks.text.fontWeight ? `${theme.legends.ticks.text.fontWeight} ` : '' + }${theme.legends.ticks.text.fontSize}px ${theme.legends.ticks.text.fontFamily}` + + ticks.forEach(tick => { + if ((theme.legends.ticks.line.strokeWidth ?? 0) > 0) { + ctx.lineWidth = Number(theme.axis.ticks.line.strokeWidth) + if (theme.axis.ticks.line.stroke) { + ctx.strokeStyle = theme.axis.ticks.line.stroke + } + ctx.lineCap = 'square' + + ctx.beginPath() + ctx.moveTo(tick.x1, tick.y1) + ctx.lineTo(tick.x2, tick.y2) + ctx.stroke() + } + + if (theme.legends.ticks.text.fill) { + ctx.fillStyle = theme.legends.ticks.text.fill + } + ctx.textAlign = tick.textHorizontalAlign === 'middle' ? 'center' : tick.textHorizontalAlign + ctx.textBaseline = tick.textVerticalAlign === 'central' ? 'middle' : tick.textVerticalAlign + + ctx.fillText(tick.text, tick.textX, tick.textY) + }) + + if (titleText) { + ctx.save() + ctx.translate(titleX, titleY) + ctx.rotate(degreesToRadians(titleRotation)) + + ctx.font = `${ + theme.legends.title.text.fontWeight ? `${theme.legends.title.text.fontWeight} ` : '' + }${theme.legends.title.text.fontSize}px ${theme.legends.title.text.fontFamily}` + if (theme.legends.title.text.fill) { + ctx.fillStyle = theme.legends.title.text.fill + } + ctx.textAlign = titleHorizontalAlign === 'middle' ? 'center' : titleHorizontalAlign + ctx.textBaseline = titleVerticalAlign + + ctx.fillText(titleText, 0, 0) + + ctx.restore() + } + + ctx.restore() +} diff --git a/packages/legends/src/compute.ts b/packages/legends/src/compute.ts index d4c867da9d..5f4b6ffa66 100644 --- a/packages/legends/src/compute.ts +++ b/packages/legends/src/compute.ts @@ -259,12 +259,25 @@ export const computeContinuousColorsLegend = ({ textVerticalAlign: 'alphabetic' | 'central' | 'hanging' }[] = [] + let width: number + let height: number + + const gradientX1: number = 0 + const gradientY1: number = 0 + let gradientX2: number = 0 + let gradientY2: number = 0 + let titleX: number let titleY: number let titleRotation: number let titleVerticalAlign: 'alphabetic' | 'hanging' if (direction === 'row') { + width = length + height = thickness + + gradientX2 = 1 + let y1: number let y2: number @@ -317,6 +330,11 @@ export const computeContinuousColorsLegend = ({ }) }) } else { + width = thickness + height = length + + gradientY2 = 1 + let x1: number let x2: number @@ -371,15 +389,19 @@ export const computeContinuousColorsLegend = ({ } return { + width, + height, + gradientX1, + gradientY1, + gradientX2, + gradientY2, colorStops, ticks: computedTicks, - title: { - text: title, - x: titleX, - y: titleY, - rotation: titleRotation, - horizontalAlign: titleAlign, - verticalAlign: titleVerticalAlign, - }, + titleText: title, + titleX, + titleY, + titleRotation, + titleHorizontalAlign: titleAlign, + titleVerticalAlign, } } diff --git a/packages/legends/src/svg/ContinuousColorsLegendSvg.tsx b/packages/legends/src/svg/ContinuousColorsLegendSvg.tsx index 137e7b90d7..feb2e57871 100644 --- a/packages/legends/src/svg/ContinuousColorsLegendSvg.tsx +++ b/packages/legends/src/svg/ContinuousColorsLegendSvg.tsx @@ -5,7 +5,6 @@ import { ContinuousColorsLegendProps } from '../types' import { continuousColorsLegendDefaults } from '../defaults' export const ContinuousColorsLegendSvg = ({ - id: _id, scale, ticks, length = continuousColorsLegendDefaults.length, @@ -20,14 +19,22 @@ export const ContinuousColorsLegendSvg = ({ titleAlign = continuousColorsLegendDefaults.titleAlign, titleOffset = continuousColorsLegendDefaults.titleOffset, }: ContinuousColorsLegendProps) => { - const id = `ContinuousColorsLegendSvgGradient.${_id}` - const { - title: computedTitle, + width, + height, + gradientX1, + gradientY1, + gradientX2, + gradientY2, ticks: computedTicks, colorStops, + titleText, + titleX, + titleY, + titleRotation, + titleVerticalAlign, + titleHorizontalAlign, } = computeContinuousColorsLegend({ - id: 'whatever', scale, ticks, length, @@ -43,37 +50,35 @@ export const ContinuousColorsLegendSvg = ({ titleOffset, }) - let width = length - let height = thickness - let gradientX2 = 0 - let gradientY2 = 0 - if (direction === 'row') { - gradientX2 = 1 - } else { - width = thickness - height = length - gradientY2 = 1 - } - const theme = useTheme() + const id = `ContinuousColorsLegendSvgGradient.${direction}.${colorStops + .map(stop => stop.offset) + .join('_')}` + return ( - + {colorStops.map(colorStop => ( ))} - {computedTitle.text && ( + {titleText && ( - {computedTitle.text} + {titleText} )} diff --git a/packages/legends/src/types.ts b/packages/legends/src/types.ts index 89391f6202..adea14c59a 100644 --- a/packages/legends/src/types.ts +++ b/packages/legends/src/types.ts @@ -150,7 +150,6 @@ export type LegendCanvasProps = { > export interface ContinuousColorsLegendProps { - id: string scale: ScaleSequential | ScaleDiverging | ScaleQuantize ticks?: number | number[] length?: number diff --git a/website/src/pages/heatmap/canvas.tsx b/website/src/pages/heatmap/canvas.tsx index 013f9ad393..8014805c6d 100644 --- a/website/src/pages/heatmap/canvas.tsx +++ b/website/src/pages/heatmap/canvas.tsx @@ -18,9 +18,9 @@ import { const initialProperties: CanvasUnmappedProps = { margin: { top: 70, - right: 90, - bottom: 120, - left: 60, + right: 60, + bottom: 20, + left: 80, }, minValue: defaults.minValue, @@ -54,7 +54,7 @@ const initialProperties: CanvasUnmappedProps = { tickRotation: 0, legend: 'country', legendPosition: 'middle', - legendOffset: 70, + legendOffset: 40, }, axisBottom: { enable: false, @@ -66,7 +66,7 @@ const initialProperties: CanvasUnmappedProps = { legendOffset: 36, }, axisLeft: { - enable: true, + enable: false, tickSize: 5, tickPadding: 5, tickRotation: 0, @@ -95,13 +95,12 @@ const initialProperties: CanvasUnmappedProps = { legends: [ { - id: 'default', - anchor: 'bottom', - translateX: 0, - translateY: 30, - length: 400, - thickness: 8, - direction: 'row', + anchor: 'left', + translateX: -50, + translateY: 0, + length: 200, + thickness: 10, + direction: 'column', tickPosition: 'after', tickSize: 3, tickSpacing: 4, diff --git a/website/src/pages/heatmap/index.tsx b/website/src/pages/heatmap/index.tsx index 9fd968f81f..e2781a981d 100644 --- a/website/src/pages/heatmap/index.tsx +++ b/website/src/pages/heatmap/index.tsx @@ -93,7 +93,6 @@ const initialProperties: SvgUnmappedProps = { legends: [ { - id: 'default', anchor: 'bottom', translateX: 0, translateY: 30,