From e22034541527483b41b11eeff47399145fdd3bcc Mon Sep 17 00:00:00 2001 From: plouc Date: Fri, 7 Jan 2022 06:47:32 +0900 Subject: [PATCH] feat(legends): add support for continuous color scale legends --- packages/legends/package.json | 4 +- packages/legends/src/compute.ts | 197 +++++++++++++++++- packages/legends/src/defaults.ts | 25 +++ packages/legends/src/hooks.ts | 5 +- packages/legends/src/index.ts | 3 + .../svg/AnchoredContinuousColorsLegendSvg.tsx | 47 +++++ .../src/svg/ContinuousColorsLegendSvg.tsx | 102 +++++++++ packages/legends/src/svg/index.ts | 2 + packages/legends/src/types.ts | 30 ++- 9 files changed, 405 insertions(+), 10 deletions(-) create mode 100644 packages/legends/src/defaults.ts create mode 100644 packages/legends/src/svg/AnchoredContinuousColorsLegendSvg.tsx create mode 100644 packages/legends/src/svg/ContinuousColorsLegendSvg.tsx diff --git a/packages/legends/package.json b/packages/legends/package.json index 996c0b1024..5de22dfdc1 100644 --- a/packages/legends/package.json +++ b/packages/legends/package.json @@ -22,7 +22,9 @@ "!dist/tsconfig.tsbuildinfo" ], "devDependencies": { - "@nivo/core": "0.78.0" + "@nivo/colors": "0.78.0", + "@nivo/core": "0.78.0", + "d3-scale": "^3.2.3" }, "peerDependencies": { "@nivo/core": "0.78.0", diff --git a/packages/legends/src/compute.ts b/packages/legends/src/compute.ts index 68540a88e0..d4c867da9d 100644 --- a/packages/legends/src/compute.ts +++ b/packages/legends/src/compute.ts @@ -1,4 +1,13 @@ -import { BoxLegendSvgProps, LegendAnchor, LegendItemDirection } from './types' +import { scaleLinear } from 'd3-scale' +import { getValueFormatter } from '@nivo/core' +import { computeContinuousColorScaleColorStops } from '@nivo/colors' +import { + BoxLegendSvgProps, + ContinuousColorsLegendProps, + LegendAnchor, + LegendItemDirection, +} from './types' +import { continuousColorsLegendDefaults } from './defaults' const isObject = (item: unknown): item is T => typeof item === 'object' && !Array.isArray(item) && item !== null @@ -132,7 +141,7 @@ export const computeItemLayout = ({ labelY = height / 2 labelAlignment = 'central' - if (justify === true) { + if (justify) { labelX = width labelAnchor = 'end' } else { @@ -147,7 +156,7 @@ export const computeItemLayout = ({ labelY = height / 2 labelAlignment = 'central' - if (justify === true) { + if (justify) { labelX = 0 labelAnchor = 'start' } else { @@ -163,7 +172,7 @@ export const computeItemLayout = ({ labelX = width / 2 labelAnchor = 'middle' - if (justify === true) { + if (justify) { labelY = height labelAlignment = 'alphabetic' } else { @@ -178,7 +187,7 @@ export const computeItemLayout = ({ labelX = width / 2 labelAnchor = 'middle' - if (justify === true) { + if (justify) { labelY = 0 labelAlignment = 'text-before-edge' } else { @@ -191,10 +200,186 @@ export const computeItemLayout = ({ return { symbolX, symbolY, - labelX, labelY, labelAnchor, labelAlignment, } } + +export const computeContinuousColorsLegend = ({ + // id, + scale, + ticks, + length = continuousColorsLegendDefaults.length, + thickness = continuousColorsLegendDefaults.thickness, + direction = continuousColorsLegendDefaults.direction, + tickPosition = continuousColorsLegendDefaults.tickPosition, + tickSize = continuousColorsLegendDefaults.tickSize, + tickSpacing = continuousColorsLegendDefaults.tickSpacing, + tickOverlap = continuousColorsLegendDefaults.tickOverlap, + tickFormat = continuousColorsLegendDefaults.tickFormat, + title, + titleAlign = continuousColorsLegendDefaults.titleAlign, + titleOffset = continuousColorsLegendDefaults.titleOffset, +}: ContinuousColorsLegendProps) => { + const domain = scale.domain() + + const positionScale = scaleLinear().domain(domain) + if (domain.length === 2) { + // sequential, quantize + positionScale.range([0, length]) + } else if (domain.length === 3) { + // diverging + positionScale.range([0, length / 2, length]) + } + + let values: number[] + if ('thresholds' in scale) { + // quantize + values = [domain[0], ...scale.thresholds(), domain[1]] + } else { + // sequential, diverging + values = Array.isArray(ticks) ? ticks : (scale as any).ticks(ticks) + } + + const colorStops = computeContinuousColorScaleColorStops(scale, 32) + + const formatValue = getValueFormatter(tickFormat) + + const computedTicks: { + x1: number + y1: number + x2: number + y2: number + text: string + textX: number + textY: number + textHorizontalAlign: 'start' | 'middle' | 'end' + textVerticalAlign: 'alphabetic' | 'central' | 'hanging' + }[] = [] + + let titleX: number + let titleY: number + let titleRotation: number + let titleVerticalAlign: 'alphabetic' | 'hanging' + + if (direction === 'row') { + let y1: number + let y2: number + + let textY: number + const textHorizontalAlign = 'middle' + let textVerticalAlign: 'alphabetic' | 'hanging' + + titleRotation = 0 + if (titleAlign === 'start') { + titleX = 0 + } else if (titleAlign === 'middle') { + titleX = length / 2 + } else { + titleX = length + } + + if (tickPosition === 'before') { + y1 = -tickSize + y2 = tickOverlap ? thickness : 0 + + textY = -tickSize - tickSpacing + textVerticalAlign = 'alphabetic' + + titleY = thickness + titleOffset + titleVerticalAlign = 'hanging' + } else { + y1 = tickOverlap ? 0 : thickness + y2 = thickness + tickSize + + textY = y2 + tickSpacing + textVerticalAlign = 'hanging' + + titleY = -titleOffset + titleVerticalAlign = 'alphabetic' + } + + values.forEach(value => { + const x = positionScale(value) + + computedTicks.push({ + x1: x, + y1, + x2: x, + y2, + text: formatValue(value), + textX: x, + textY, + textHorizontalAlign, + textVerticalAlign, + }) + }) + } else { + let x1: number + let x2: number + + let textX: number + let textHorizontalAlign: 'start' | 'end' + const textVerticalAlign = 'central' + + titleRotation = -90 + if (titleAlign === 'start') { + titleY = length + } else if (titleAlign === 'middle') { + titleY = length / 2 + } else { + titleY = 0 + } + + if (tickPosition === 'before') { + x1 = -tickSize + x2 = tickOverlap ? thickness : 0 + + textX = x1 - tickSpacing + textHorizontalAlign = 'end' + + titleX = thickness + titleOffset + titleVerticalAlign = 'hanging' + } else { + x1 = tickOverlap ? 0 : thickness + x2 = thickness + tickSize + + textX = x2 + tickSpacing + textHorizontalAlign = 'start' + + titleX = -titleOffset + titleVerticalAlign = 'alphabetic' + } + + values.forEach(value => { + const y = positionScale(value) + + computedTicks.push({ + x1, + y1: y, + x2, + y2: y, + text: formatValue(value), + textX, + textY: y, + textHorizontalAlign, + textVerticalAlign, + }) + }) + } + + return { + colorStops, + ticks: computedTicks, + title: { + text: title, + x: titleX, + y: titleY, + rotation: titleRotation, + horizontalAlign: titleAlign, + verticalAlign: titleVerticalAlign, + }, + } +} diff --git a/packages/legends/src/defaults.ts b/packages/legends/src/defaults.ts new file mode 100644 index 0000000000..3699ba59d6 --- /dev/null +++ b/packages/legends/src/defaults.ts @@ -0,0 +1,25 @@ +import { ContinuousColorsLegendProps } from './types' + +export const continuousColorsLegendDefaults: { + length: NonNullable + thickness: NonNullable + direction: NonNullable + tickPosition: NonNullable + tickSize: NonNullable + tickSpacing: NonNullable + tickOverlap: NonNullable + tickFormat: NonNullable + titleAlign: NonNullable + titleOffset: NonNullable +} = { + length: 200, + thickness: 16, + direction: 'row', + tickPosition: 'after', + tickSize: 4, + tickSpacing: 3, + tickOverlap: false, + tickFormat: (value: number) => `${value}`, + titleAlign: 'start', + titleOffset: 4, +} diff --git a/packages/legends/src/hooks.ts b/packages/legends/src/hooks.ts index ece0e956ae..b5e3355635 100644 --- a/packages/legends/src/hooks.ts +++ b/packages/legends/src/hooks.ts @@ -34,8 +34,9 @@ export const useQuantizeColorScaleLegendData = ({ color: domainValue, } }) - if (reverse === true) items.reverse() + + if (reverse) items.reverse() return items - }, [overriddenDomain, scale, reverse]) + }, [overriddenDomain, scale, reverse, separator, valueFormat]) } diff --git a/packages/legends/src/index.ts b/packages/legends/src/index.ts index 7785f93765..ea80ddfa8c 100644 --- a/packages/legends/src/index.ts +++ b/packages/legends/src/index.ts @@ -1,5 +1,8 @@ export * from './svg' export * from './canvas' +export * from './defaults' export * from './hooks' export * from './props' export * from './types' +export * from './compute' +export * from './defaults' diff --git a/packages/legends/src/svg/AnchoredContinuousColorsLegendSvg.tsx b/packages/legends/src/svg/AnchoredContinuousColorsLegendSvg.tsx new file mode 100644 index 0000000000..f2c598a6bf --- /dev/null +++ b/packages/legends/src/svg/AnchoredContinuousColorsLegendSvg.tsx @@ -0,0 +1,47 @@ +import { AnchoredContinuousColorsLegendProps } from '../types' +import { computePositionFromAnchor } from '../compute' +import { continuousColorsLegendDefaults } from '../defaults' +import { ContinuousColorsLegendSvg } from './ContinuousColorsLegendSvg' + +export const AnchoredContinuousColorsLegendSvg = ({ + containerWidth, + containerHeight, + anchor, + translateX = 0, + translateY = 0, + length = continuousColorsLegendDefaults.length, + thickness = continuousColorsLegendDefaults.thickness, + direction = continuousColorsLegendDefaults.direction, + ...legendProps +}: AnchoredContinuousColorsLegendProps) => { + let width: number + let height: number + if (direction === 'row') { + width = length + height = thickness + } else { + width = thickness + height = length + } + + const { x, y } = computePositionFromAnchor({ + anchor, + translateX, + translateY, + containerWidth, + containerHeight, + width, + height, + }) + + return ( + + + + ) +} diff --git a/packages/legends/src/svg/ContinuousColorsLegendSvg.tsx b/packages/legends/src/svg/ContinuousColorsLegendSvg.tsx new file mode 100644 index 0000000000..137e7b90d7 --- /dev/null +++ b/packages/legends/src/svg/ContinuousColorsLegendSvg.tsx @@ -0,0 +1,102 @@ +import { Fragment } from 'react' +import { useTheme } from '@nivo/core' +import { computeContinuousColorsLegend } from '../compute' +import { ContinuousColorsLegendProps } from '../types' +import { continuousColorsLegendDefaults } from '../defaults' + +export const ContinuousColorsLegendSvg = ({ + id: _id, + scale, + ticks, + length = continuousColorsLegendDefaults.length, + thickness = continuousColorsLegendDefaults.thickness, + direction = continuousColorsLegendDefaults.direction, + tickPosition = continuousColorsLegendDefaults.tickPosition, + tickSize = continuousColorsLegendDefaults.tickSize, + tickSpacing = continuousColorsLegendDefaults.tickSpacing, + tickOverlap = continuousColorsLegendDefaults.tickOverlap, + tickFormat = continuousColorsLegendDefaults.tickFormat, + title, + titleAlign = continuousColorsLegendDefaults.titleAlign, + titleOffset = continuousColorsLegendDefaults.titleOffset, +}: ContinuousColorsLegendProps) => { + const id = `ContinuousColorsLegendSvgGradient.${_id}` + + const { + title: computedTitle, + ticks: computedTicks, + colorStops, + } = computeContinuousColorsLegend({ + id: 'whatever', + scale, + ticks, + length, + thickness, + direction, + tickPosition, + tickSize, + tickSpacing, + tickOverlap, + tickFormat, + title, + titleAlign, + 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() + + return ( + + + + {colorStops.map(colorStop => ( + + ))} + + + {computedTitle.text && ( + + {computedTitle.text} + + )} + + {computedTicks.map((tick, index) => ( + + + + {tick.text} + + + ))} + + ) +} diff --git a/packages/legends/src/svg/index.ts b/packages/legends/src/svg/index.ts index 12d5321c24..af3ca596d0 100644 --- a/packages/legends/src/svg/index.ts +++ b/packages/legends/src/svg/index.ts @@ -1,4 +1,6 @@ export * from './symbols' +export * from './AnchoredContinuousColorsLegendSvg' export * from './BoxLegendSvg' +export * from './ContinuousColorsLegendSvg' export * from './LegendSvg' export * from './LegendSvgItem' diff --git a/packages/legends/src/types.ts b/packages/legends/src/types.ts index ab51e2689f..89391f6202 100644 --- a/packages/legends/src/types.ts +++ b/packages/legends/src/types.ts @@ -1,6 +1,7 @@ import * as React from 'react' +import { ScaleDiverging, ScaleQuantize, ScaleSequential } from 'd3-scale' +import { CompleteTheme, ValueFormat } from '@nivo/core' import { SymbolProps } from './svg/symbols/types' -import { CompleteTheme } from '@nivo/core' /** * This can be used to add effect on legends on interaction. @@ -147,3 +148,30 @@ export type LegendCanvasProps = { | 'itemDirection' | 'itemTextColor' > + +export interface ContinuousColorsLegendProps { + id: string + scale: ScaleSequential | ScaleDiverging | ScaleQuantize + ticks?: number | number[] + length?: number + thickness?: number + direction?: LegendDirection + borderWidth?: number + borderColor?: string + tickPosition?: 'before' | 'after' + tickSize?: number + tickSpacing?: number + tickOverlap?: boolean + tickFormat?: ValueFormat + title?: string + titleAlign?: 'start' | 'middle' | 'end' + titleOffset?: number +} + +export type AnchoredContinuousColorsLegendProps = ContinuousColorsLegendProps & { + anchor: LegendAnchor + translateX?: number + translateY?: number + containerWidth: number + containerHeight: number +}