Skip to content

Commit

Permalink
feat(legends): add support for canvas continuous color legends and ad…
Browse files Browse the repository at this point in the history
…d it to heatmap
  • Loading branch information
plouc committed Jan 12, 2022
1 parent d92ecd9 commit e3e8f00
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 53 deletions.
8 changes: 4 additions & 4 deletions packages/colors/src/scales/continuousColorScale.ts
Expand Up @@ -72,7 +72,7 @@ export const computeContinuousColorScaleColorStops = (
if ('thresholds' in scale) {
const stops: {
key: string
offset: string
offset: number
stopColor: string
}[] = []

Expand All @@ -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,
})
})
Expand All @@ -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)}`,
}))
}
20 changes: 18 additions & 2 deletions packages/heatmap/src/HeatMapCanvas.tsx
Expand Up @@ -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'
Expand Down Expand Up @@ -51,7 +52,7 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
labelTextColor = canvasDefaultProps.labelTextColor as HeatMapCommonProps<Datum>['labelTextColor'],
colors = canvasDefaultProps.colors as HeatMapCommonProps<Datum>['colors'],
emptyColor = canvasDefaultProps.emptyColor,
// legends = canvasDefaultProps.legends,
legends = canvasDefaultProps.legends,
// annotations = canvasDefaultProps.annotations as HeatMapCommonProps<Datum>['annotations'],
isInteractive = canvasDefaultProps.isInteractive,
// onMouseEnter,
Expand All @@ -74,7 +75,10 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
partialMargin
)

const { xScale, yScale, cells, activeCell, setActiveCell } = useHeatMap<Datum, ExtraProps>({
const { xScale, yScale, cells, activeCell, setActiveCell, colorScale } = useHeatMap<
Datum,
ExtraProps
>({
data,
valueFormat,
width: innerWidth,
Expand Down Expand Up @@ -160,6 +164,16 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
cells.forEach(cell => {
renderCell(ctx, { cell, enableLabels, theme })
})
} else if (layer === 'legends' && colorScale !== null) {
legends.forEach(legend => {
renderContinuousColorLegendToCanvas(ctx, {
...legend,
containerWidth: innerWidth,
containerHeight: innerHeight,
scale: colorScale,
theme,
})
})
}
})
}, [
Expand All @@ -182,6 +196,8 @@ const InnerHeatMapCanvas = <Datum extends HeatMapDatum, ExtraProps extends objec
yScale,
theme,
enableLabels,
colorScale,
legends,
pixelRatio,
])

Expand Down
143 changes: 141 additions & 2 deletions packages/legends/src/canvas.ts
@@ -1,5 +1,12 @@
import { computeDimensions, computePositionFromAnchor, computeItemLayout } from './compute'
import { LegendCanvasProps } from './types'
import { CompleteTheme, degreesToRadians } from '@nivo/core'
import {
computeDimensions,
computePositionFromAnchor,
computeItemLayout,
computeContinuousColorsLegend,
} from './compute'
import { AnchoredContinuousColorsLegendProps, LegendCanvasProps } from './types'
import { continuousColorsLegendDefaults } from './defaults'

const textAlignMapping = {
start: 'left',
Expand Down Expand Up @@ -94,3 +101,135 @@ export const renderLegendToCanvas = (

ctx.restore()
}

export const renderContinuousColorLegendToCanvas = (
ctx: CanvasRenderingContext2D,
{
containerWidth,
containerHeight,
anchor,
translateX = 0,
translateY = 0,
scale,
length = continuousColorsLegendDefaults.length,
thickness = continuousColorsLegendDefaults.thickness,
direction = continuousColorsLegendDefaults.direction,
ticks: _ticks,
tickPosition = continuousColorsLegendDefaults.tickPosition,
tickSize = continuousColorsLegendDefaults.tickSize,
tickSpacing = continuousColorsLegendDefaults.tickSpacing,
tickOverlap = continuousColorsLegendDefaults.tickOverlap,
tickFormat = continuousColorsLegendDefaults.tickFormat,
title,
titleAlign = continuousColorsLegendDefaults.titleAlign,
titleOffset = continuousColorsLegendDefaults.titleOffset,
theme,
}: AnchoredContinuousColorsLegendProps & {
theme: CompleteTheme
}
) => {
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()
}
38 changes: 30 additions & 8 deletions packages/legends/src/compute.ts
Expand Up @@ -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

Expand Down Expand Up @@ -317,6 +330,11 @@ export const computeContinuousColorsLegend = ({
})
})
} else {
width = thickness
height = length

gradientY2 = 1

let x1: number
let x2: number

Expand Down Expand Up @@ -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,
}
}
51 changes: 28 additions & 23 deletions packages/legends/src/svg/ContinuousColorsLegendSvg.tsx
Expand Up @@ -5,7 +5,6 @@ import { ContinuousColorsLegendProps } from '../types'
import { continuousColorsLegendDefaults } from '../defaults'

export const ContinuousColorsLegendSvg = ({
id: _id,
scale,
ticks,
length = continuousColorsLegendDefaults.length,
Expand All @@ -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,
Expand All @@ -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 (
<g>
<defs>
<linearGradient id={id} x2={gradientX2} y2={gradientY2}>
<linearGradient
id={id}
x1={gradientX1}
y1={gradientY1}
x2={gradientX2}
y2={gradientY2}
>
{colorStops.map(colorStop => (
<stop {...colorStop} />
))}
</linearGradient>
</defs>
{computedTitle.text && (
{titleText && (
<text
transform={`translate(${computedTitle.x}, ${computedTitle.y}) rotate(${computedTitle.rotation})`}
textAnchor={computedTitle.horizontalAlign}
dominantBaseline={computedTitle.verticalAlign}
transform={`translate(${titleX}, ${titleY}) rotate(${titleRotation})`}
textAnchor={titleHorizontalAlign}
dominantBaseline={titleVerticalAlign}
style={theme.legends.title.text}
>
{computedTitle.text}
{titleText}
</text>
)}
<rect width={width} height={height} fill={`url(#${id}`} />
Expand Down

0 comments on commit e3e8f00

Please sign in to comment.