diff --git a/packages/heatmap/package.json b/packages/heatmap/package.json index 831fff9796..cd2d1ddc7b 100644 --- a/packages/heatmap/package.json +++ b/packages/heatmap/package.json @@ -29,6 +29,7 @@ "!dist/tsconfig.tsbuildinfo" ], "dependencies": { + "@nivo/annotations": "0.78.0", "@nivo/axes": "0.78.0", "@nivo/colors": "0.78.0", "@nivo/tooltip": "0.78.0", @@ -40,7 +41,6 @@ }, "peerDependencies": { "@nivo/core": "0.78.0", - "prop-types": ">= 15.5.10 < 16.0.0", "react": ">= 16.14.0 < 18.0.0" }, "publishConfig": { diff --git a/packages/heatmap/src/HeatMap.tsx b/packages/heatmap/src/HeatMap.tsx index 465379a961..666e6a2e88 100644 --- a/packages/heatmap/src/HeatMap.tsx +++ b/packages/heatmap/src/HeatMap.tsx @@ -12,6 +12,7 @@ import { import { useHeatMap } from './hooks' import { svgDefaultProps } from './defaults' import { HeatMapCells } from './HeatMapCells' +import { HeatMapCellAnnotations } from './HeatMapCellAnnotations' type InnerHeatMapProps = Omit< HeatMapSvgProps, @@ -52,6 +53,7 @@ const InnerHeatMap = ({ colors = svgDefaultProps.colors, nanColor = svgDefaultProps.nanColor, legends = svgDefaultProps.legends, + annotations = svgDefaultProps.annotations as HeatMapCommonProps['annotations'], isInteractive = svgDefaultProps.isInteractive, onMouseEnter, onMouseMove, @@ -93,6 +95,7 @@ const InnerHeatMap = ({ axes: null, cells: null, legends: null, + annotations: null, } if (layers.includes('grid')) { @@ -158,6 +161,16 @@ const InnerHeatMap = ({ ) } + if (layers.includes('annotations') && annotations.length > 0) { + layerById.annotations = ( + + key="annotations" + cells={cells} + annotations={annotations} + /> + ) + } + return ( { + cells: ComputedCell[] + annotations: NonNullable['annotations']> +} + +export const HeatMapCellAnnotations = ({ + cells, + annotations, +}: HeatMapCellAnnotationsProps) => { + const boundAnnotations = useCellAnnotations(cells, annotations) + + return ( + <> + {boundAnnotations.map((annotation, i) => ( + + ))} + + ) +} diff --git a/packages/heatmap/src/compute.ts b/packages/heatmap/src/compute.ts index 522e032c6d..38924fc82a 100644 --- a/packages/heatmap/src/compute.ts +++ b/packages/heatmap/src/compute.ts @@ -33,7 +33,7 @@ export const computeCells = & { layers: LayerId[] } = { - layers: ['grid', 'axes', 'cells', 'legends'], + layers: ['grid', 'axes', 'cells', 'legends', 'annotations'], minValue: 'auto', maxValue: 'auto', @@ -50,6 +50,7 @@ export const commonDefaultProps: Omit< nanColor: '#000000', legends: [], + annotations: [], isInteractive: true, hoverTarget: 'rowColumn', diff --git a/packages/heatmap/src/hooks.ts b/packages/heatmap/src/hooks.ts index a3de99ee7a..c13de0c4d0 100644 --- a/packages/heatmap/src/hooks.ts +++ b/packages/heatmap/src/hooks.ts @@ -16,6 +16,8 @@ import { } from './types' import { commonDefaultProps } from './defaults' import { computeCells } from './compute' +import { ComputedNode, InputNode } from '@nivo/network' +import { AnnotationMatcher, useAnnotations } from '@nivo/annotations' export const useComputeCells = ({ data, @@ -321,3 +323,25 @@ export const useHeatMap = < } */ } + +const getCellAnnotationPosition = (cell: ComputedCell) => ({ + x: cell.x, + y: cell.y, +}) + +const getCellAnnotationDimensions = (cell: ComputedCell) => ({ + size: Math.max(cell.width, cell.height), + width: cell.width, + height: cell.height, +}) + +export const useCellAnnotations = ( + cells: ComputedCell[], + annotations: AnnotationMatcher>[] +) => + useAnnotations>({ + data: cells, + annotations, + getPosition: getCellAnnotationPosition, + getDimensions: getCellAnnotationDimensions, + }) diff --git a/packages/heatmap/src/types.ts b/packages/heatmap/src/types.ts index 30ad884da0..77078a6d4b 100644 --- a/packages/heatmap/src/types.ts +++ b/packages/heatmap/src/types.ts @@ -11,15 +11,16 @@ import { import { AxisProps } from '@nivo/axes' import { InheritedColorConfig, ContinuousColorScaleConfig } from '@nivo/colors' import { AnchoredContinuousColorsLegendProps } from '@nivo/legends' +import { AnnotationMatcher } from '@nivo/annotations' export interface HeatMapDatum { x: string | number - y: number + y: number | null | undefined } export interface DefaultHeatMapDatum { x: string - y: number + y: number | null | undefined } export type HeatMapSerie = { @@ -64,7 +65,7 @@ export interface HeatMapDataProps[] } -export type LayerId = 'grid' | 'axes' | 'cells' | 'legends' +export type LayerId = 'grid' | 'axes' | 'cells' | 'legends' | 'annotations' export interface CustomLayerProps { cells: ComputedCell[] } @@ -138,6 +139,8 @@ export type HeatMapCommonProps = { legends: Omit[] + annotations: AnnotationMatcher>[] + isInteractive: boolean hoverTarget: 'cell' | 'row' | 'column' | 'rowColumn' tooltip: TooltipComponent diff --git a/website/src/components/controls/generics/RadioControl.tsx b/website/src/components/controls/generics/RadioControl.tsx index ef8bd1cccc..e96a8a3375 100644 --- a/website/src/components/controls/generics/RadioControl.tsx +++ b/website/src/components/controls/generics/RadioControl.tsx @@ -20,7 +20,7 @@ export const RadioControl = memo( property, flavors, currentFlavor, - config, + config: { choices, columns }, value, onChange, context, @@ -36,7 +36,7 @@ export const RadioControl = memo( supportedFlavors={property.flavors} > - + {property.help} ) diff --git a/website/src/components/controls/types.ts b/website/src/components/controls/types.ts index 64a6ac4b5f..435fb86dad 100644 --- a/website/src/components/controls/types.ts +++ b/website/src/components/controls/types.ts @@ -32,6 +32,7 @@ export interface ChoicesControlConfig { export interface RadioControlConfig { type: 'radio' + columns?: number choices: { label: string value: string diff --git a/website/src/components/controls/ui/Radio.tsx b/website/src/components/controls/ui/Radio.tsx index c82297728e..06294cd397 100644 --- a/website/src/components/controls/ui/Radio.tsx +++ b/website/src/components/controls/ui/Radio.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components' interface RadioProps { value: string + columns?: number options: { value: string label: string @@ -10,9 +11,9 @@ interface RadioProps { onChange: (e: any) => void } -export const Radio = memo(({ options, value, onChange }: RadioProps) => { +export const Radio = memo(({ options, columns = 2, value, onChange }: RadioProps) => { return ( - + {options.map(option => ( { ) }) -const Container = styled.div` +const Container = styled.div<{ + columns: number +}>` display: grid; align-items: center; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(${({ columns }) => columns}, 1fr); border: 1px solid ${({ theme }) => theme.colors.border}; font-size: 14px; border-right-width: 0; diff --git a/website/src/data/components/heatmap/mapper.tsx b/website/src/data/components/heatmap/mapper.tsx index 006de5c795..571aee19e5 100644 --- a/website/src/data/components/heatmap/mapper.tsx +++ b/website/src/data/components/heatmap/mapper.tsx @@ -41,7 +41,7 @@ const CustomCell = ({ export default settingsMapper( { valueFormat: mapFormat, - cellShape: value => { + cellShape: (value: string) => { if (value === `Custom(props) => (…)`) return CustomCell return value }, @@ -49,6 +49,12 @@ export default settingsMapper( axisRight: mapAxis('right'), axisBottom: mapAxis('bottom'), axisLeft: mapAxis('left'), + legends: (legends: any[]) => { + return legends.map(legend => ({ + ...legend, + tickFormat: mapFormat(legend.tickFormat), + })) + }, }, { exclude: ['enable axisTop', 'enable axisRight', 'enable axisBottom', 'enable axisLeft'], diff --git a/website/src/data/components/heatmap/props.ts b/website/src/data/components/heatmap/props.ts index d2db49b846..8b9eabbc1e 100644 --- a/website/src/data/components/heatmap/props.ts +++ b/website/src/data/components/heatmap/props.ts @@ -6,6 +6,7 @@ import { axes, isInteractive, commonAccessibilityProps, + annotations, } from '../../../lib/chart-properties' import { ChartProperty, Flavor } from '../../../types' @@ -149,6 +150,7 @@ const props: ChartProperty[] = [ key: 'colors', group: 'Style', type: 'ContinuousColorScaleConfig | ((datum) => string)', + defaultValue: defaults.colors, control: { type: 'continuous_colors', }, @@ -345,6 +347,11 @@ const props: ChartProperty[] = [ type: 'boolean', control: { type: 'switch' }, }, + { + key: 'tickFormat', + type: 'string | (value: number) => string | number', + control: { type: 'valueFormat' }, + }, { key: 'title', type: 'string', @@ -355,6 +362,7 @@ const props: ChartProperty[] = [ type: `'start' | 'middle' | 'end'`, control: { type: 'radio', + columns: 3, choices: [ { label: 'start', @@ -473,6 +481,19 @@ const props: ChartProperty[] = [ })), }, }, + annotations({ + target: 'nodes', + flavors: allFlavors, + createDefaults: { + type: 'rect', + match: { id: 'Germany.Bus' }, + note: 'Bus in Germany', + noteX: 120, + noteY: -130, + offset: 6, + noteTextOffset: 5, + }, + }), ...commonAccessibilityProps(allFlavors), ...motionProperties(['svg'], defaults, 'react-spring'), ] diff --git a/website/src/pages/heatmap/index.tsx b/website/src/pages/heatmap/index.tsx index 3742d9ba6f..911de3ce03 100644 --- a/website/src/pages/heatmap/index.tsx +++ b/website/src/pages/heatmap/index.tsx @@ -9,282 +9,91 @@ import { ComponentTemplate } from '../../components/components/ComponentTemplate import meta from '../../data/components/heatmap/meta.yml' import mapper from '../../data/components/heatmap/mapper' import { groups } from '../../data/components/heatmap/props' -import { generateLightDataSet } from '../../data/components/heatmap/generator' import { graphql, useStaticQuery } from 'gatsby' -const getRandomValue = () => -100 + Math.random() * 200 +interface XYRangeStaticValues { + values: string[] | number[] +} -const getData = () => [ - { - id: 'japan', - data: [ - { - x: 'plane', - y: getRandomValue(), - }, - { - x: 'helicopter', - y: getRandomValue(), - }, - { - x: 'boat', - y: getRandomValue(), - }, - { - x: 'train', - y: getRandomValue(), - }, - { - x: 'subway', - y: getRandomValue(), - }, - { - x: 'bus', - y: getRandomValue(), - }, - { - x: 'car', - y: getRandomValue(), - }, - { - x: 'moto', - y: getRandomValue(), - }, - { - x: 'bicycle', - y: getRandomValue(), - }, - { - x: 'horse', - y: getRandomValue(), - }, - { - x: 'skateboard', - y: getRandomValue(), - }, - { - x: 'others', - y: getRandomValue(), - }, - ], - }, - { - id: 'france', - data: [ - { - x: 'plane', - y: getRandomValue(), - }, - { - x: 'helicopter', - y: getRandomValue(), - }, - { - x: 'boat', - y: getRandomValue(), - }, - { - x: 'train', - y: getRandomValue(), - }, - { - x: 'subway', - y: getRandomValue(), - }, - { - x: 'bus', - y: getRandomValue(), - }, - { - x: 'car', - y: getRandomValue(), - }, - { - x: 'moto', - y: getRandomValue(), - }, - { - x: 'bicycle', - y: getRandomValue(), - }, - { - x: 'horse', - y: getRandomValue(), - }, - { - x: 'skateboard', - y: getRandomValue(), - }, - { - x: 'others', - y: getRandomValue(), - }, - ], - }, - { - id: 'us', - data: [ - { - x: 'plane', - y: getRandomValue(), - }, - { - x: 'helicopter', - y: getRandomValue(), - }, - { - x: 'boat', - y: getRandomValue(), - }, - { - x: 'train', - y: getRandomValue(), - }, - { - x: 'subway', - y: getRandomValue(), - }, - { - x: 'bus', - y: getRandomValue(), - }, - { - x: 'car', - y: getRandomValue(), - }, - { - x: 'moto', - y: getRandomValue(), - }, - { - x: 'bicycle', - y: getRandomValue(), - }, - { - x: 'horse', - y: getRandomValue(), - }, - { - x: 'skateboard', - y: getRandomValue(), - }, - { - x: 'others', - y: getRandomValue(), - }, - ], - }, - { - id: 'germany', - data: [ - { - x: 'plane', - y: getRandomValue(), - }, - { - x: 'helicopter', - y: getRandomValue(), - }, - { - x: 'boat', - y: getRandomValue(), - }, - { - x: 'train', - y: getRandomValue(), - }, - { - x: 'subway', - y: getRandomValue(), - }, - { - x: 'bus', - y: getRandomValue(), - }, - { - x: 'car', - y: getRandomValue(), - }, - { - x: 'moto', - y: getRandomValue(), - }, - { - x: 'bicycle', - y: getRandomValue(), - }, - { - x: 'horse', - y: getRandomValue(), - }, - { - x: 'skateboard', - y: getRandomValue(), - }, - { - x: 'others', - y: getRandomValue(), - }, - ], - }, - { - id: 'norway', - data: [ - { - x: 'plane', - y: getRandomValue(), - }, - { - x: 'helicopter', - y: getRandomValue(), - }, - { - x: 'boat', - y: getRandomValue(), - }, - { - x: 'train', - y: getRandomValue(), - }, - { - x: 'subway', - y: getRandomValue(), - }, - { - x: 'bus', - y: getRandomValue(), - }, - { - x: 'car', - y: getRandomValue(), - }, - { - x: 'moto', - y: getRandomValue(), - }, - { - x: 'bicycle', - y: getRandomValue(), - }, - { - x: 'horse', - y: getRandomValue(), - }, - { - x: 'skateboard', - y: getRandomValue(), - }, - { - x: 'others', - y: getRandomValue(), - }, - ], - }, -] +interface XYRandomNumericValues { + length: number + min: number + max: number + round?: boolean +} + +type XYRangeValues = XYRangeStaticValues | XYRandomNumericValues + +const generateXYSeries = ({ + serieIds, + x, + y, +}: { + serieIds: string[] + x: XYRangeValues + y: XYRangeValues +}) => { + const xLength = 'length' in x ? x.length : x.values.length + + let getX: (index: number) => string | number + if ('values' in x) { + getX = (index: number) => x.values[index] + } else { + getX = () => { + let xValue = x.min + Math.random() * (x.max - x.min) + if (x.round) { + xValue = Math.round(xValue) + } + + return xValue + } + } + + let getY: (index: number) => string | number + if ('values' in y) { + getY = (index: number) => y.values[index] + } else { + getY = () => { + let yValue = y.min + Math.random() * (y.max - y.min) + if (y.round) { + yValue = Math.round(yValue) + } + + return yValue + } + } + + return serieIds.map(serieId => { + return { + id: serieId, + data: Array.from({ length: xLength }).map((_, index) => { + return { + x: getX(index), + y: getY(index), + } + }), + } + }) +} + +const getData = () => { + return generateXYSeries({ + serieIds: ['Japan', 'France', 'US', 'Germany', 'Norway', 'Iceland', 'UK', 'Vietnam'], + x: { + values: ['Plane', 'Train', 'Subway', 'Bus', 'Car', 'Moto', 'Bicycle', 'Others'], + }, + y: { + length: NaN, + min: -100_000, + max: 100_000, + round: true, + }, + }) +} const initialProperties = { margin: { - top: 90, + top: 60, right: 90, bottom: 60, left: 90, @@ -292,7 +101,7 @@ const initialProperties = { minValue: defaults.minValue, maxValue: defaults.maxValue, - valueFormat: { format: '>-d', enabled: true }, + valueFormat: { format: '>-.2s', enabled: true }, forceSquare: defaults.forceSquare, sizeVariation: 0, @@ -320,7 +129,7 @@ const initialProperties = { tickRotation: 0, legend: 'country', legendPosition: 'middle', - legendOffset: 64, + legendOffset: 70, }, axisBottom: { enable: false, @@ -340,15 +149,15 @@ const initialProperties = { tickRotation: 0, legend: 'country', legendPosition: 'middle', - legendOffset: -64, + legendOffset: -72, }, colors: { type: 'diverging', scheme: 'red_yellow_blue', divergeAt: 0.5, - minValue: -100, - maxValue: 100, + minValue: -100_000, + maxValue: 100_000, }, cellComponent: 'rect', cellOpacity: 1, @@ -390,13 +199,15 @@ const initialProperties = { tickSize: 3, tickSpacing: 4, tickOverlap: false, - tickFormat: Math.round, + tickFormat: { format: '>-.2s', enabled: true }, title: 'Value →', titleAlign: 'start', titleOffset: 4, }, ], + annotations: [], + animate: defaults.animate, motionConfig: defaults.motionConfig,