Skip to content

Commit e3e8f00

Browse files
committedJan 12, 2022
feat(legends): add support for canvas continuous color legends and add it to heatmap
1 parent d92ecd9 commit e3e8f00

File tree

8 files changed

+232
-53
lines changed

8 files changed

+232
-53
lines changed
 

‎packages/colors/src/scales/continuousColorScale.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const computeContinuousColorScaleColorStops = (
7272
if ('thresholds' in scale) {
7373
const stops: {
7474
key: string
75-
offset: string
75+
offset: number
7676
stopColor: string
7777
}[] = []
7878

@@ -82,12 +82,12 @@ export const computeContinuousColorScaleColorStops = (
8282

8383
stops.push({
8484
key: `${index}.0`,
85-
offset: `${Math.round(normalizedScale(start) * 100)}%`,
85+
offset: normalizedScale(start),
8686
stopColor: color,
8787
})
8888
stops.push({
8989
key: `${index}.1`,
90-
offset: `${Math.round(normalizedScale(end) * 100)}%`,
90+
offset: normalizedScale(end),
9191
stopColor: color,
9292
})
9393
})
@@ -106,7 +106,7 @@ export const computeContinuousColorScaleColorStops = (
106106

107107
return ((colorStopsScale as any).ticks(steps) as number[]).map((value: number) => ({
108108
key: `${value}`,
109-
offset: `${Math.round(value * 100)}%`,
109+
offset: value,
110110
stopColor: `${colorStopsScale(value)}`,
111111
}))
112112
}

‎packages/heatmap/src/HeatMapCanvas.tsx

+18-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, createElement } from 'react'
22
import { getRelativeCursor, isCursorInRect, useDimensions, useTheme, Container } from '@nivo/core'
33
import { renderAxesToCanvas, renderGridLinesToCanvas } from '@nivo/axes'
44
import { useTooltip } from '@nivo/tooltip'
5+
import { renderContinuousColorLegendToCanvas } from '@nivo/legends'
56
import { useHeatMap } from './hooks'
67
import { renderRect, renderCircle } from './canvas'
78
import { canvasDefaultProps } from './defaults'
@@ -51,7 +52,7 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
5152
labelTextColor = canvasDefaultProps.labelTextColor as HeatMapCommonProps<Datum>['labelTextColor'],
5253
colors = canvasDefaultProps.colors as HeatMapCommonProps<Datum>['colors'],
5354
emptyColor = canvasDefaultProps.emptyColor,
54-
// legends = canvasDefaultProps.legends,
55+
legends = canvasDefaultProps.legends,
5556
// annotations = canvasDefaultProps.annotations as HeatMapCommonProps<Datum>['annotations'],
5657
isInteractive = canvasDefaultProps.isInteractive,
5758
// onMouseEnter,
@@ -74,7 +75,10 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
7475
partialMargin
7576
)
7677

77-
const { xScale, yScale, cells, activeCell, setActiveCell } = useHeatMap<Datum, ExtraProps>({
78+
const { xScale, yScale, cells, activeCell, setActiveCell, colorScale } = useHeatMap<
79+
Datum,
80+
ExtraProps
81+
>({
7882
data,
7983
valueFormat,
8084
width: innerWidth,
@@ -160,6 +164,16 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
160164
cells.forEach(cell => {
161165
renderCell(ctx, { cell, enableLabels, theme })
162166
})
167+
} else if (layer === 'legends' && colorScale !== null) {
168+
legends.forEach(legend => {
169+
renderContinuousColorLegendToCanvas(ctx, {
170+
...legend,
171+
containerWidth: innerWidth,
172+
containerHeight: innerHeight,
173+
scale: colorScale,
174+
theme,
175+
})
176+
})
163177
}
164178
})
165179
}, [
@@ -182,6 +196,8 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
182196
yScale,
183197
theme,
184198
enableLabels,
199+
colorScale,
200+
legends,
185201
pixelRatio,
186202
])
187203

‎packages/legends/src/canvas.ts

+141-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import { computeDimensions, computePositionFromAnchor, computeItemLayout } from './compute'
2-
import { LegendCanvasProps } from './types'
1+
import { CompleteTheme, degreesToRadians } from '@nivo/core'
2+
import {
3+
computeDimensions,
4+
computePositionFromAnchor,
5+
computeItemLayout,
6+
computeContinuousColorsLegend,
7+
} from './compute'
8+
import { AnchoredContinuousColorsLegendProps, LegendCanvasProps } from './types'
9+
import { continuousColorsLegendDefaults } from './defaults'
310

411
const textAlignMapping = {
512
start: 'left',
@@ -94,3 +101,135 @@ export const renderLegendToCanvas = (
94101

95102
ctx.restore()
96103
}
104+
105+
export const renderContinuousColorLegendToCanvas = (
106+
ctx: CanvasRenderingContext2D,
107+
{
108+
containerWidth,
109+
containerHeight,
110+
anchor,
111+
translateX = 0,
112+
translateY = 0,
113+
scale,
114+
length = continuousColorsLegendDefaults.length,
115+
thickness = continuousColorsLegendDefaults.thickness,
116+
direction = continuousColorsLegendDefaults.direction,
117+
ticks: _ticks,
118+
tickPosition = continuousColorsLegendDefaults.tickPosition,
119+
tickSize = continuousColorsLegendDefaults.tickSize,
120+
tickSpacing = continuousColorsLegendDefaults.tickSpacing,
121+
tickOverlap = continuousColorsLegendDefaults.tickOverlap,
122+
tickFormat = continuousColorsLegendDefaults.tickFormat,
123+
title,
124+
titleAlign = continuousColorsLegendDefaults.titleAlign,
125+
titleOffset = continuousColorsLegendDefaults.titleOffset,
126+
theme,
127+
}: AnchoredContinuousColorsLegendProps & {
128+
theme: CompleteTheme
129+
}
130+
) => {
131+
const {
132+
width,
133+
height,
134+
gradientX1,
135+
gradientY1,
136+
gradientX2,
137+
gradientY2,
138+
colorStops,
139+
ticks,
140+
titleText,
141+
titleX,
142+
titleY,
143+
titleRotation,
144+
titleVerticalAlign,
145+
titleHorizontalAlign,
146+
} = computeContinuousColorsLegend({
147+
scale,
148+
ticks: _ticks,
149+
length,
150+
thickness,
151+
direction,
152+
tickPosition,
153+
tickSize,
154+
tickSpacing,
155+
tickOverlap,
156+
tickFormat,
157+
title,
158+
titleAlign,
159+
titleOffset,
160+
})
161+
162+
const { x, y } = computePositionFromAnchor({
163+
anchor,
164+
translateX,
165+
translateY,
166+
containerWidth,
167+
containerHeight,
168+
width,
169+
height,
170+
})
171+
172+
ctx.save()
173+
ctx.translate(x, y)
174+
175+
const gradient = ctx.createLinearGradient(
176+
gradientX1 * width,
177+
gradientY1 * height,
178+
gradientX2 * width,
179+
gradientY2 * height
180+
)
181+
colorStops.forEach(colorStop => {
182+
gradient.addColorStop(colorStop.offset, colorStop.stopColor)
183+
})
184+
185+
ctx.fillStyle = gradient
186+
ctx.fillRect(0, 0, width, height)
187+
188+
ctx.font = `${
189+
theme.legends.ticks.text.fontWeight ? `${theme.legends.ticks.text.fontWeight} ` : ''
190+
}${theme.legends.ticks.text.fontSize}px ${theme.legends.ticks.text.fontFamily}`
191+
192+
ticks.forEach(tick => {
193+
if ((theme.legends.ticks.line.strokeWidth ?? 0) > 0) {
194+
ctx.lineWidth = Number(theme.axis.ticks.line.strokeWidth)
195+
if (theme.axis.ticks.line.stroke) {
196+
ctx.strokeStyle = theme.axis.ticks.line.stroke
197+
}
198+
ctx.lineCap = 'square'
199+
200+
ctx.beginPath()
201+
ctx.moveTo(tick.x1, tick.y1)
202+
ctx.lineTo(tick.x2, tick.y2)
203+
ctx.stroke()
204+
}
205+
206+
if (theme.legends.ticks.text.fill) {
207+
ctx.fillStyle = theme.legends.ticks.text.fill
208+
}
209+
ctx.textAlign = tick.textHorizontalAlign === 'middle' ? 'center' : tick.textHorizontalAlign
210+
ctx.textBaseline = tick.textVerticalAlign === 'central' ? 'middle' : tick.textVerticalAlign
211+
212+
ctx.fillText(tick.text, tick.textX, tick.textY)
213+
})
214+
215+
if (titleText) {
216+
ctx.save()
217+
ctx.translate(titleX, titleY)
218+
ctx.rotate(degreesToRadians(titleRotation))
219+
220+
ctx.font = `${
221+
theme.legends.title.text.fontWeight ? `${theme.legends.title.text.fontWeight} ` : ''
222+
}${theme.legends.title.text.fontSize}px ${theme.legends.title.text.fontFamily}`
223+
if (theme.legends.title.text.fill) {
224+
ctx.fillStyle = theme.legends.title.text.fill
225+
}
226+
ctx.textAlign = titleHorizontalAlign === 'middle' ? 'center' : titleHorizontalAlign
227+
ctx.textBaseline = titleVerticalAlign
228+
229+
ctx.fillText(titleText, 0, 0)
230+
231+
ctx.restore()
232+
}
233+
234+
ctx.restore()
235+
}

‎packages/legends/src/compute.ts

+30-8
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,25 @@ export const computeContinuousColorsLegend = ({
259259
textVerticalAlign: 'alphabetic' | 'central' | 'hanging'
260260
}[] = []
261261

262+
let width: number
263+
let height: number
264+
265+
const gradientX1: number = 0
266+
const gradientY1: number = 0
267+
let gradientX2: number = 0
268+
let gradientY2: number = 0
269+
262270
let titleX: number
263271
let titleY: number
264272
let titleRotation: number
265273
let titleVerticalAlign: 'alphabetic' | 'hanging'
266274

267275
if (direction === 'row') {
276+
width = length
277+
height = thickness
278+
279+
gradientX2 = 1
280+
268281
let y1: number
269282
let y2: number
270283

@@ -317,6 +330,11 @@ export const computeContinuousColorsLegend = ({
317330
})
318331
})
319332
} else {
333+
width = thickness
334+
height = length
335+
336+
gradientY2 = 1
337+
320338
let x1: number
321339
let x2: number
322340

@@ -371,15 +389,19 @@ export const computeContinuousColorsLegend = ({
371389
}
372390

373391
return {
392+
width,
393+
height,
394+
gradientX1,
395+
gradientY1,
396+
gradientX2,
397+
gradientY2,
374398
colorStops,
375399
ticks: computedTicks,
376-
title: {
377-
text: title,
378-
x: titleX,
379-
y: titleY,
380-
rotation: titleRotation,
381-
horizontalAlign: titleAlign,
382-
verticalAlign: titleVerticalAlign,
383-
},
400+
titleText: title,
401+
titleX,
402+
titleY,
403+
titleRotation,
404+
titleHorizontalAlign: titleAlign,
405+
titleVerticalAlign,
384406
}
385407
}

‎packages/legends/src/svg/ContinuousColorsLegendSvg.tsx

+28-23
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { ContinuousColorsLegendProps } from '../types'
55
import { continuousColorsLegendDefaults } from '../defaults'
66

77
export const ContinuousColorsLegendSvg = ({
8-
id: _id,
98
scale,
109
ticks,
1110
length = continuousColorsLegendDefaults.length,
@@ -20,14 +19,22 @@ export const ContinuousColorsLegendSvg = ({
2019
titleAlign = continuousColorsLegendDefaults.titleAlign,
2120
titleOffset = continuousColorsLegendDefaults.titleOffset,
2221
}: ContinuousColorsLegendProps) => {
23-
const id = `ContinuousColorsLegendSvgGradient.${_id}`
24-
2522
const {
26-
title: computedTitle,
23+
width,
24+
height,
25+
gradientX1,
26+
gradientY1,
27+
gradientX2,
28+
gradientY2,
2729
ticks: computedTicks,
2830
colorStops,
31+
titleText,
32+
titleX,
33+
titleY,
34+
titleRotation,
35+
titleVerticalAlign,
36+
titleHorizontalAlign,
2937
} = computeContinuousColorsLegend({
30-
id: 'whatever',
3138
scale,
3239
ticks,
3340
length,
@@ -43,37 +50,35 @@ export const ContinuousColorsLegendSvg = ({
4350
titleOffset,
4451
})
4552

46-
let width = length
47-
let height = thickness
48-
let gradientX2 = 0
49-
let gradientY2 = 0
50-
if (direction === 'row') {
51-
gradientX2 = 1
52-
} else {
53-
width = thickness
54-
height = length
55-
gradientY2 = 1
56-
}
57-
5853
const theme = useTheme()
5954

55+
const id = `ContinuousColorsLegendSvgGradient.${direction}.${colorStops
56+
.map(stop => stop.offset)
57+
.join('_')}`
58+
6059
return (
6160
<g>
6261
<defs>
63-
<linearGradient id={id} x2={gradientX2} y2={gradientY2}>
62+
<linearGradient
63+
id={id}
64+
x1={gradientX1}
65+
y1={gradientY1}
66+
x2={gradientX2}
67+
y2={gradientY2}
68+
>
6469
{colorStops.map(colorStop => (
6570
<stop {...colorStop} />
6671
))}
6772
</linearGradient>
6873
</defs>
69-
{computedTitle.text && (
74+
{titleText && (
7075
<text
71-
transform={`translate(${computedTitle.x}, ${computedTitle.y}) rotate(${computedTitle.rotation})`}
72-
textAnchor={computedTitle.horizontalAlign}
73-
dominantBaseline={computedTitle.verticalAlign}
76+
transform={`translate(${titleX}, ${titleY}) rotate(${titleRotation})`}
77+
textAnchor={titleHorizontalAlign}
78+
dominantBaseline={titleVerticalAlign}
7479
style={theme.legends.title.text}
7580
>
76-
{computedTitle.text}
81+
{titleText}
7782
</text>
7883
)}
7984
<rect width={width} height={height} fill={`url(#${id}`} />

‎packages/legends/src/types.ts

-1
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,6 @@ export type LegendCanvasProps = {
150150
>
151151

152152
export interface ContinuousColorsLegendProps {
153-
id: string
154153
scale: ScaleSequential<string> | ScaleDiverging<string> | ScaleQuantize<string>
155154
ticks?: number | number[]
156155
length?: number

‎website/src/pages/heatmap/canvas.tsx

+11-12
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ import {
1818
const initialProperties: CanvasUnmappedProps = {
1919
margin: {
2020
top: 70,
21-
right: 90,
22-
bottom: 120,
23-
left: 60,
21+
right: 60,
22+
bottom: 20,
23+
left: 80,
2424
},
2525

2626
minValue: defaults.minValue,
@@ -54,7 +54,7 @@ const initialProperties: CanvasUnmappedProps = {
5454
tickRotation: 0,
5555
legend: 'country',
5656
legendPosition: 'middle',
57-
legendOffset: 70,
57+
legendOffset: 40,
5858
},
5959
axisBottom: {
6060
enable: false,
@@ -66,7 +66,7 @@ const initialProperties: CanvasUnmappedProps = {
6666
legendOffset: 36,
6767
},
6868
axisLeft: {
69-
enable: true,
69+
enable: false,
7070
tickSize: 5,
7171
tickPadding: 5,
7272
tickRotation: 0,
@@ -95,13 +95,12 @@ const initialProperties: CanvasUnmappedProps = {
9595

9696
legends: [
9797
{
98-
id: 'default',
99-
anchor: 'bottom',
100-
translateX: 0,
101-
translateY: 30,
102-
length: 400,
103-
thickness: 8,
104-
direction: 'row',
98+
anchor: 'left',
99+
translateX: -50,
100+
translateY: 0,
101+
length: 200,
102+
thickness: 10,
103+
direction: 'column',
105104
tickPosition: 'after',
106105
tickSize: 3,
107106
tickSpacing: 4,

‎website/src/pages/heatmap/index.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ const initialProperties: SvgUnmappedProps = {
9393

9494
legends: [
9595
{
96-
id: 'default',
9796
anchor: 'bottom',
9897
translateX: 0,
9998
translateY: 30,

0 commit comments

Comments
 (0)
Please sign in to comment.