diff --git a/packages/components/examples/report-card/empowerment-metric/segmented-circular-gauge.stories.tsx b/packages/components/examples/report-card/empowerment-metric/segmented-circular-gauge.stories.tsx new file mode 100644 index 00000000..eb31f27f --- /dev/null +++ b/packages/components/examples/report-card/empowerment-metric/segmented-circular-gauge.stories.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + Arc, + ArcSegments, + ArcSegmentsSweep, + ArcUnitLabel, + Arcs, + Chart, +} from '../../../src'; + +const Neutral400 = '#42526E'; +const Neutral100 = '#6B778C'; +const FontWeightNormal = 400; +const FontWeightBold = 600; + +const unitLabelStyle = (at: number, value: number) => { + const isPrimaryValue = at === value; + const fontWeight = isPrimaryValue ? FontWeightBold : FontWeightNormal; + const fill = isPrimaryValue ? Neutral400 : Neutral100; + return { + fontFamily: '"Inter"', + fontSize: '16px', + fontStyle: 'normal', + fontWeight, + fill, + }; +}; + +const ReportCardEmpowermentMetricSegmentedCircularGauge = (props: { + valueMin: number; + valueMax: number; + value: number; + valueContinuous: number; + radius: number; + radiusRatio: number; + segments: number; + padAngle: number; + cornerRadius: number | string; +}) => { + const { + valueMin, + valueMax, + value, + valueContinuous, + radius, + radiusRatio, + segments, + padAngle, + cornerRadius, + } = props; + + const startAngleOffset = 0; // -0.05; + + const Neutral80 = '#DFE1E6'; + const Yellow300 = '#FFE380'; + const Yellow500 = '#FFAB00'; + + return ( + + + + + + + + + Sad + + + Fair + + + Okay + + + Good + + + Happy + + + + + + + + ); +}; + +const meta: Meta = { + title: 'Examples/Report Card/Empowerment Metric/SegmentedCircularGauge', + component: ReportCardEmpowermentMetricSegmentedCircularGauge, + args: { + valueMin: 1, + valueMax: 5, + value: 2, + radius: 100, + radiusRatio: 0.74, + padAngle: 0.042, + cornerRadius: '1.5px', + segments: 52, + valueContinuous: 0.9, + }, + argTypes: { + value: { + control: { + type: 'range', + min: 1, + max: 5, + step: 1, + }, + }, + valueContinuous: { + control: { + type: 'range', + min: 0.9, + max: 5, + step: 0.1, + }, + }, + radius: { + control: { + type: 'range', + min: 1, + max: 100, + step: 1, + }, + }, + radiusRatio: { + control: { + type: 'range', + min: 0, + max: 1, + step: 0.01, + }, + }, + padAngle: { + control: { + type: 'range', + min: 0, + max: 0.1, + step: 0.001, + }, + }, + segments: { + control: { + type: 'range', + min: 1, + max: 52, + step: 1, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = {}; diff --git a/packages/components/src/Arc/Arc.stories.tsx b/packages/components/src/Arc/Arc.stories.tsx index 8b228b53..5168758c 100644 --- a/packages/components/src/Arc/Arc.stories.tsx +++ b/packages/components/src/Arc/Arc.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; import { Arc } from './Arc'; import { ArcSweep } from '../ArcSweep'; import { Chart } from '../Chart'; diff --git a/packages/components/src/ArcCircle/ArcCircle.stories.tsx b/packages/components/src/ArcCircle/ArcCircle.stories.tsx index 35bff0c0..d7ce2404 100644 --- a/packages/components/src/ArcCircle/ArcCircle.stories.tsx +++ b/packages/components/src/ArcCircle/ArcCircle.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; import { ArcCircle } from './ArcCircle'; import { Chart } from '../Chart'; import { generateArc } from '../Arc/generateArc'; diff --git a/packages/components/src/ArcSegments/ArcSegments.stories.tsx b/packages/components/src/ArcSegments/ArcSegments.stories.tsx new file mode 100644 index 00000000..18e47901 --- /dev/null +++ b/packages/components/src/ArcSegments/ArcSegments.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ArcSegments } from './ArcSegments'; +import { ArcSegmentsSweep } from '../ArcSegmentsSweep'; +import { Chart } from '../Chart'; +import { generateArc } from '../Arc/generateArc'; + +const dim = 252 as const; + +const meta: Meta = { + title: 'ArcSegments', + component: ArcSegments, + decorators: (Story) => ( + + + + ), + argTypes: { + padAngle: { + control: { + type: 'range', + min: 0, + max: 0.5, + step: 0.005, + }, + }, + count: { + control: { + type: 'range', + min: 0, + max: 50, + step: 1, + }, + }, + cornerRadius: { + control: { + type: 'text', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const circularArc = generateArc({ + valueMin: 0, + valueMax: 126, + angleMin: 0, + angleMax: 2 * Math.PI, + radius: 126, + ratio: 0.77, + cornerRadius: '50%', +}); + +export const Circular: Story = { + args: { + arc: circularArc, + padAngle: 0.05, + count: 20, + cornerRadius: '2px', + children: [ + , + , + ], + }, +}; + +const radialArc = generateArc({ + valueMin: 1, + valueMax: 3, + angleMin: (-1 * Math.PI) / 2, + angleMax: Math.PI / 2, + radius: 126, + ratio: 0.77, + cornerRadius: '50%', +}); + +export const Radial: Story = { + args: { + arc: radialArc, + padAngle: 0.05, + count: 20, + cornerRadius: '2px', + children: [ + , + , + ], + }, +}; diff --git a/packages/components/src/ArcSegments/ArcSegments.tsx b/packages/components/src/ArcSegments/ArcSegments.tsx new file mode 100644 index 00000000..8cfcf568 --- /dev/null +++ b/packages/components/src/ArcSegments/ArcSegments.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ArcSegmentsProps } from './ArcSegmentsProp'; +import { generateArcSegments } from './generateArcSegments'; + +export const ArcSegments = (props: ArcSegmentsProps) => { + const { arc, children, cornerRadius, count, padAngle } = props; + + if (arc === undefined) { + throw Error( + 'Oops! `ArcSegments` received `arc: undefined`. Did you mean to either (1) render as a child of `Arc`? or (2) pass `arc` from `generateArc`?' + ); + } + + const arcSegments = generateArcSegments({ + count, + padAngle, + cornerRadius, + }); + + const extraProps = { arc: Object.assign(arc, { segments: arcSegments }) }; + + return React.Children.map(children, (child) => { + // @ts-expect-error TODO + return React.cloneElement(child, extraProps); + }); +}; diff --git a/packages/components/src/ArcSegments/ArcSegmentsProp.ts b/packages/components/src/ArcSegments/ArcSegmentsProp.ts new file mode 100644 index 00000000..edc037e1 --- /dev/null +++ b/packages/components/src/ArcSegments/ArcSegmentsProp.ts @@ -0,0 +1,22 @@ +import { ReactNode } from 'react'; +import { ArcOut } from '../Arc/generateArc'; + +export interface ArcSegmentsProps { + /** + * The number of segments in the arc. + */ + count: number; + /** + * The angular separation in radians between adjacent segments. + */ + padAngle: number; + /** + * The default corner radius of any segment located on this arc. + */ + cornerRadius?: number | string; + /** + * The arc on which the segments are located. + */ + arc?: ArcOut; + children: ReactNode; +} diff --git a/packages/components/src/ArcSegments/generateArcSegments.ts b/packages/components/src/ArcSegments/generateArcSegments.ts new file mode 100644 index 00000000..a847b3cf --- /dev/null +++ b/packages/components/src/ArcSegments/generateArcSegments.ts @@ -0,0 +1,21 @@ +export type ArcsSegmentsIn = { + count: number; + padAngle: number; + cornerRadius?: number | string; +}; + +export type ArcSegmentsOut = { + count: number; + padAngle: number; + cornerRadius?: number | string; +}; + +export function generateArcSegments(params: ArcsSegmentsIn): ArcSegmentsOut { + const arcSegments = { + count: params.count, + padAngle: params.padAngle, + cornerRadius: params.cornerRadius, + }; + + return arcSegments; +} diff --git a/packages/components/src/ArcSegments/index.ts b/packages/components/src/ArcSegments/index.ts new file mode 100644 index 00000000..04a1da38 --- /dev/null +++ b/packages/components/src/ArcSegments/index.ts @@ -0,0 +1,3 @@ +export * from './ArcSegments'; +export * from './ArcSegmentsProp'; +export * from './generateArcSegments'; diff --git a/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweep.stories.tsx b/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweep.stories.tsx new file mode 100644 index 00000000..81376202 --- /dev/null +++ b/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweep.stories.tsx @@ -0,0 +1,155 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ArcSegmentsSweep } from './ArcSegmentsSweep'; +import { Chart } from '../Chart'; +import { generateArc } from '../Arc/generateArc'; +import { generateArcSegments } from '../ArcSegments/generateArcSegments'; + +const dim = 252 as const; + +const meta: Meta = { + title: 'ArcSegmentsSweep', + component: ArcSegmentsSweep, + decorators: (Story) => ( + + + + ), +}; + +export default meta; + +type Story = StoryObj; + +const circularArc = { + ...generateArc({ + valueMin: 0, + valueMax: 126, + angleMin: 0, + angleMax: 2 * Math.PI, + radius: 126, + ratio: 0.77, + }), + segments: generateArcSegments({ + cornerRadius: '2px', + count: 20, + padAngle: 0.02, + }), +}; + +// circular; min = from = to < max +export const CircularMin: Story = { + args: { + from: undefined, + to: 0, + cornerRadius: undefined, + arc: circularArc, + }, +}; + +// circular; min = from < to < max +export const CircularPartial: Story = { + args: { + from: undefined, + to: 100, + cornerRadius: undefined, + arc: circularArc, + }, +}; + +// circular; min < from < to < max +export const CircularPartial2: Story = { + args: { + from: 20, + to: 110, + cornerRadius: undefined, + arc: circularArc, + }, +}; + +// circular; min < from < to = max +export const CircularPartial3: Story = { + args: { + from: 20, + to: 126, + cornerRadius: undefined, + arc: circularArc, + }, +}; + +// circular; min = from < to = max +export const CircularMax: Story = { + args: { + from: undefined, + to: 126, + cornerRadius: undefined, + arc: circularArc, + }, +}; + +const radialArc = { + ...generateArc({ + valueMin: 1, + valueMax: 3, + angleMin: (-1 * Math.PI) / 2, + angleMax: Math.PI / 2, + radius: 126, + ratio: 0.77, + cornerRadius: '50%', + }), + segments: generateArcSegments({ + cornerRadius: '2px', + count: 15, + padAngle: 0.02, + }), +}; + +// radial; min = from = to < max +export const RadialMin: Story = { + args: { + from: undefined, + to: 1, + cornerRadius: undefined, + arc: radialArc, + }, +}; + +// radial; min = from < to < max +export const RadialPartial: Story = { + args: { + from: undefined, + to: 2, + cornerRadius: undefined, + arc: radialArc, + }, +}; + +// radial; min = from < to < max; cornerRadius = 0 +export const RadialPartialSharp: Story = { + args: { + from: undefined, + to: 2, + cornerRadius: 0, + arc: radialArc, + }, +}; + +// radial; min = from < to < max; cornerRadius = 4px +export const RadialPartialSoft: Story = { + args: { + from: undefined, + to: 2, + cornerRadius: '8px', + arc: radialArc, + }, +}; + +// radial; min = from < to = max +export const RadialMax: Story = { + args: { + from: undefined, + to: 3, + cornerRadius: undefined, + arc: radialArc, + }, +}; diff --git a/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweep.tsx b/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweep.tsx new file mode 100644 index 00000000..a351f2cd --- /dev/null +++ b/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweep.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ArcSegmentsSweepProps } from './ArcSegmentsSweepProps'; +import { generateArcSegmentsSweep } from './generateArcSegmentsSweep'; + +export type ArcSegmentsSweepRef = SVGGElement; + +export const ArcSegmentsSweep = React.forwardRef< + ArcSegmentsSweepRef, + ArcSegmentsSweepProps +>((props, ref) => { + const { from, to, cornerRadius, arc, renderProps, ...other } = props; + + if (arc === undefined) { + throw Error( + 'Oops! `ArcSegmentsSweep` received `arc: undefined`. Did you mean to either (1) render as a child of `Arc`? or (2) pass `arc` from `generateArc`?' + ); + } + + const sweeps = generateArcSegmentsSweep({ + from, + to, + cornerRadius, + arc, + segments: arc.segments, + }); + + return ( + + {sweeps.map((sweep, i) => { + const baseProps = { key: i, d: sweep.d }; + + const props = + renderProps === undefined + ? baseProps + : typeof renderProps === 'function' + ? renderProps(baseProps, i) + : Object.assign(baseProps, renderProps); + + return ; + })} + + ); +}); diff --git a/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweepProps.ts b/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweepProps.ts new file mode 100644 index 00000000..b1b9e208 --- /dev/null +++ b/packages/components/src/ArcSegmentsSweep/ArcSegmentsSweepProps.ts @@ -0,0 +1,30 @@ +import React from 'react'; +import { ArcOut } from '../Arc/generateArc'; +import { ArcSegmentsOut } from '../ArcSegments/generateArcSegments'; + +export interface ArcSegmentsSweepProps { + /** + * The value from which the arc sweep starts. Defaults to the minimum value. + */ + from?: number; + /** + * The value from which the arc sweep ends. Defaults to the maximum value. + */ + to?: number; + /** + * The corner radius of the arc sweep. Defaults to 0. Overrides the corner radius set on the `arc`. + */ + cornerRadius?: number | string; + /** + * The arc on which the sweep is located. + */ + arc?: ArcOut & { + segments: ArcSegmentsOut; + }; + renderProps?: + | React.SVGTextElementAttributes + | (( + props: { d: string }, + index: number + ) => React.SVGTextElementAttributes); +} diff --git a/packages/components/src/ArcSegmentsSweep/generateArcSegmentsSweep.ts b/packages/components/src/ArcSegmentsSweep/generateArcSegmentsSweep.ts new file mode 100644 index 00000000..4bf7f34d --- /dev/null +++ b/packages/components/src/ArcSegmentsSweep/generateArcSegmentsSweep.ts @@ -0,0 +1,78 @@ +import { arc as d3Arc, pie as d3Pie } from 'd3-shape'; +import { ArcOut } from '../Arc/generateArc'; +import { getNumericalValue, getValueSegmentsScale } from '../utils'; + +type ArcSegmentsSweepIn = { + from?: number; + to?: number; + cornerRadius?: number | string; + arc: ArcOut; + segments: { + count: number; + padAngle: number; + cornerRadius?: number | string; + }; +}; + +type ArcSegmentsSweepOut = { + d: string; +}[]; + +export function generateArcSegmentsSweep( + params: ArcSegmentsSweepIn +): ArcSegmentsSweepOut { + const segmentScale = getValueSegmentsScale({ + value: { + min: params.arc.value.min, + max: params.arc.value.max, + }, + segments: params.segments.count, + }); + + const from = params.from ?? params.arc.value.min; + const to = params.to ?? params.arc.value.max; + + const startSegment = segmentScale(from); + const endSegment = segmentScale(to); + + const outerRadius = params.arc.radius; + const innerRadius = outerRadius * params.arc.ratio; + const width = outerRadius - innerRadius; + const cornerRadiusWide = + params.cornerRadius ?? + params.segments.cornerRadius ?? + DEFAULT_CORNER_RADIUS; + const cornerRadius = getNumericalValue(cornerRadiusWide, width); + + const arcGenerator = d3Arc().cornerRadius(cornerRadius); + + // value doesn't matter, because the data is not used + const data = Array.from({ length: params.segments.count }, () => 1); + const pieGenerator = d3Pie() + .padAngle(params.segments.padAngle) + .startAngle(params.arc.angle.min) + .endAngle(params.arc.angle.max); + const pieArcs = pieGenerator(data); + const pieArcSweeps = pieArcs + .filter( + (datum) => + datum.index + 1 >= startSegment && datum.index + 1 <= endSegment + ) + .map( + (datum) => + arcGenerator({ + outerRadius, + innerRadius, + startAngle: datum.startAngle, + endAngle: datum.endAngle, + padAngle: datum.padAngle, + // cast out `null` because the rendering context is not given + }) as string + ); + + const dMultiple = pieArcSweeps.map((d) => ({ d })); + + return dMultiple; +} + +const DEFAULT_CORNER_RADIUS = 0; diff --git a/packages/components/src/ArcSegmentsSweep/index.ts b/packages/components/src/ArcSegmentsSweep/index.ts new file mode 100644 index 00000000..de5bcaf3 --- /dev/null +++ b/packages/components/src/ArcSegmentsSweep/index.ts @@ -0,0 +1,3 @@ +export * from './ArcSegmentsSweep'; +export * from './ArcSegmentsSweepProps'; +export * from './generateArcSegmentsSweep'; diff --git a/packages/components/src/ArcSweep/ArcSweep.stories.tsx b/packages/components/src/ArcSweep/ArcSweep.stories.tsx index 42cd4fcf..51b72244 100644 --- a/packages/components/src/ArcSweep/ArcSweep.stories.tsx +++ b/packages/components/src/ArcSweep/ArcSweep.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; import { ArcSweep } from './ArcSweep'; import { Chart } from '../Chart'; import { generateArc } from '../Arc/generateArc'; diff --git a/packages/components/src/Arcs/Arcs.stories.tsx b/packages/components/src/Arcs/Arcs.stories.tsx index 45e6ed5b..9ef25deb 100644 --- a/packages/components/src/Arcs/Arcs.stories.tsx +++ b/packages/components/src/Arcs/Arcs.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; import { Arc } from '../Arc'; import { Arcs } from './Arcs'; import { ArcSweep } from '../ArcSweep'; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 24fb4461..de93513a 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,5 +1,7 @@ export * from './Arc'; export * from './ArcCircle'; +export * from './ArcSegments'; +export * from './ArcSegmentsSweep'; export * from './ArcSweep'; export * from './ArcUnitLabel'; export * from './Arcs'; diff --git a/packages/components/src/utils/arc-segments.ts b/packages/components/src/utils/arc-segments.ts new file mode 100644 index 00000000..495d9063 --- /dev/null +++ b/packages/components/src/utils/arc-segments.ts @@ -0,0 +1,34 @@ +import { + ScaleLinear as D3ScaleLinear, + scaleLinear as d3ScaleLinear, +} from 'd3-scale'; +import { Value } from './value'; + +/** + * A amount of segments. Non-negative integer. + */ +export type Segments = number; + +/** + * A linear, continuous scale that maps the domain of values to the range of segments. + */ +export type ValueSegmentsScale = D3ScaleLinear; + +/** + * Construct a linear, continuous scale that maps the domain of values to an amount of segments. + */ +export const getValueSegmentsScale = (params: { + value: { + min: Value; + max: Value; + }; + segments: Segments; +}): ValueSegmentsScale => { + const domain = [params.value.min, params.value.max]; + + const range = [0, params.segments]; + + const scale = d3ScaleLinear().domain(domain).range(range); + + return scale; +}; diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 046e1001..74a92453 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './arc'; export * from './arc-circle'; +export * from './arc-segments'; export * from './chart'; export * from './circle'; export * from './scale';