From e59c7c67325a1ece6da6de3a64f9eb1852b04ff2 Mon Sep 17 00:00:00 2001 From: William Kelley Date: Tue, 18 Jun 2024 11:51:12 -0400 Subject: [PATCH] feat(dataviz): add Linear components --- .../src/LinearScale/LinearScale.stories.tsx | 80 +++++++++++++++++ .../dataviz/src/LinearScale/LinearScale.tsx | 25 ++++++ .../src/LinearScale/LinearScaleProps.ts | 9 ++ packages/dataviz/src/LinearScale/index.ts | 2 + .../LinearUnitLabel.stories.tsx | 90 +++++++++++++++++++ .../src/LinearUnitLabel/LinearUnitLabel.tsx | 49 ++++++++++ .../LinearUnitLabel/LinearUnitLabelProps.ts | 14 +++ packages/dataviz/src/LinearUnitLabel/index.ts | 2 + .../src/LinearUnits/LinearUnits.stories.tsx | 63 +++++++++++++ .../dataviz/src/LinearUnits/LinearUnits.tsx | 41 +++++++++ .../src/LinearUnits/LinearUnitsProps.ts | 17 ++++ packages/dataviz/src/LinearUnits/index.ts | 2 + packages/dataviz/src/index.ts | 3 + packages/dataviz/src/utils/index.ts | 4 + packages/dataviz/src/utils/linear-ctx.ts | 13 +++ packages/dataviz/src/utils/linear-scale.ts | 26 ++++++ .../dataviz/src/utils/linear-unit-label.ts | 80 +++++++++++++++++ packages/dataviz/src/utils/linear-units.ts | 16 ++++ 18 files changed, 536 insertions(+) create mode 100644 packages/dataviz/src/LinearScale/LinearScale.stories.tsx create mode 100644 packages/dataviz/src/LinearScale/LinearScale.tsx create mode 100644 packages/dataviz/src/LinearScale/LinearScaleProps.ts create mode 100644 packages/dataviz/src/LinearScale/index.ts create mode 100644 packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.stories.tsx create mode 100644 packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.tsx create mode 100644 packages/dataviz/src/LinearUnitLabel/LinearUnitLabelProps.ts create mode 100644 packages/dataviz/src/LinearUnitLabel/index.ts create mode 100644 packages/dataviz/src/LinearUnits/LinearUnits.stories.tsx create mode 100644 packages/dataviz/src/LinearUnits/LinearUnits.tsx create mode 100644 packages/dataviz/src/LinearUnits/LinearUnitsProps.ts create mode 100644 packages/dataviz/src/LinearUnits/index.ts create mode 100644 packages/dataviz/src/utils/linear-ctx.ts create mode 100644 packages/dataviz/src/utils/linear-scale.ts create mode 100644 packages/dataviz/src/utils/linear-unit-label.ts create mode 100644 packages/dataviz/src/utils/linear-units.ts diff --git a/packages/dataviz/src/LinearScale/LinearScale.stories.tsx b/packages/dataviz/src/LinearScale/LinearScale.stories.tsx new file mode 100644 index 00000000..5503fd42 --- /dev/null +++ b/packages/dataviz/src/LinearScale/LinearScale.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { LinearScale } from './LinearScale'; +import { LinearUnits } from '../LinearUnits'; +import { LinearUnitLabel } from '../LinearUnitLabel'; + +const meta: Meta = { + title: 'LinearScale', + component: LinearScale, + decorators: (Story) => ( + + + + ), + argTypes: { + valueMin: { + control: { + type: 'range', + min: 0, + max: 100, + step: 1, + }, + }, + valueMax: { + control: { + type: 'range', + min: 0, + max: 100, + step: 1, + }, + }, + lengthMin: { + control: { + type: 'range', + min: 0, + max: 200, + step: 1, + }, + }, + lengthMax: { + control: { + type: 'range', + min: 0, + max: 200, + step: 1, + }, + }, + }, + args: { + valueMin: 0, + valueMax: 100, + lengthMin: 0, + lengthMax: 200, + children: ( + + Label + Label + Label + + ), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const LengthMaxPartial: Story = { + name: 'lengthMax=100 (partial)', + args: { + lengthMax: 100, + }, +}; + +export const LengthMaxMax: Story = { + name: 'lengthMax=200 (max)', + args: { + lengthMax: 200, + }, +}; diff --git a/packages/dataviz/src/LinearScale/LinearScale.tsx b/packages/dataviz/src/LinearScale/LinearScale.tsx new file mode 100644 index 00000000..825198f2 --- /dev/null +++ b/packages/dataviz/src/LinearScale/LinearScale.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { LinearScaleChildProps, LinearScaleProps } from './LinearScaleProps'; + +export const LinearScale = (props: LinearScaleProps) => { + const { children, lengthMax, lengthMin, valueMax, valueMin } = props; + + const ctx = { + linearScale: { + lengthMax, + lengthMin, + valueMax, + valueMin, + }, + }; + + return React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + ctx: child.props.ctx ?? ctx, + }); + } + + return child; + }); +}; diff --git a/packages/dataviz/src/LinearScale/LinearScaleProps.ts b/packages/dataviz/src/LinearScale/LinearScaleProps.ts new file mode 100644 index 00000000..a1629353 --- /dev/null +++ b/packages/dataviz/src/LinearScale/LinearScaleProps.ts @@ -0,0 +1,9 @@ +import React from 'react'; +import { LinearScaleParams } from '../utils/linear-scale'; +import { LinearCtx, WithCtx } from '../utils'; + +export interface LinearScaleProps extends LinearScaleParams { + children?: React.ReactNode; +} + +export type LinearScaleChildProps = WithCtx>; diff --git a/packages/dataviz/src/LinearScale/index.ts b/packages/dataviz/src/LinearScale/index.ts new file mode 100644 index 00000000..65dc5f39 --- /dev/null +++ b/packages/dataviz/src/LinearScale/index.ts @@ -0,0 +1,2 @@ +export * from './LinearScale'; +export * from './LinearScaleProps'; diff --git a/packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.stories.tsx b/packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.stories.tsx new file mode 100644 index 00000000..5be71e8a --- /dev/null +++ b/packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { LinearUnitLabel } from './LinearUnitLabel'; + +const meta: Meta = { + title: 'LinearUnitLabel', + component: LinearUnitLabel, + decorators: (Story) => ( + + + + ), + argTypes: { + at: { + control: { + type: 'range', + min: 0, + max: 100, + step: 1, + }, + }, + dominantBaseline: { + control: { type: 'select' }, + options: [ + 'text-before-edge', + 'text-after-edge', + 'middle', + 'hanging', + 'alphabetic', + 'auto', + ], + }, + offset: { + control: { type: 'range', min: -20, max: 20, step: 1 }, + }, + }, + args: { + children: <>Label, + ctx: { + linearScale: { + valueMin: 0, + valueMax: 100, + lengthMin: 0, + lengthMax: 200, + }, + linearUnits: {}, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const AtMin: Story = { + name: 'at=(min)', + args: { + at: 0, + }, +}; + +export const AtMid: Story = { + name: 'at=(mid)', + args: { + at: 50, + }, +}; + +export const AtMax: Story = { + name: 'at=(max)', + args: { + at: 100, + }, +}; + +export const Offset4: Story = { + name: 'offset=4 at=..', + args: { + at: 0, + offset: 4, + }, +}; + +export const OffsetNeg4: Story = { + name: 'offset=-4 at=..', + args: { + at: 0, + offset: -4, + }, +}; diff --git a/packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.tsx b/packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.tsx new file mode 100644 index 00000000..5c03ea35 --- /dev/null +++ b/packages/dataviz/src/LinearUnitLabel/LinearUnitLabel.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { + LinearUnitLabelElement, + LinearUnitLabelProps, +} from './LinearUnitLabelProps'; +import { drawLinearUnitLabel, mergeCtxOverrides } from '../utils'; + +export const LinearUnitLabel = React.forwardRef< + LinearUnitLabelElement, + LinearUnitLabelProps +>(function LinearUnitLabel(props, ref) { + const { + at, + dominantBaseline: dominantBaselineProp, + offset, + ctx: ctxProp, + overrides, + ...other + } = props; + + if (ctxProp === undefined) { + throw Error( + 'Oops! `LinearUnits` received `ctx: undefined`. Did you mean to either (1) render as a child of `LinearScale`? or (2) specify `ctx` explicitly?' + ); + } + + const ctx = mergeCtxOverrides(ctxProp, overrides); + + const { x, y, textAnchor, dominantBaseline } = drawLinearUnitLabel({ + scale: ctx.linearScale, + units: ctx.linearUnits, + unitLabel: { + at, + dominantBaseline: dominantBaselineProp, + offset, + }, + }); + + return ( + + ); +}); diff --git a/packages/dataviz/src/LinearUnitLabel/LinearUnitLabelProps.ts b/packages/dataviz/src/LinearUnitLabel/LinearUnitLabelProps.ts new file mode 100644 index 00000000..b6a333fe --- /dev/null +++ b/packages/dataviz/src/LinearUnitLabel/LinearUnitLabelProps.ts @@ -0,0 +1,14 @@ +import React from 'react'; +import { LinearCtx, LinearUnitLabelParams, WithOverridableCtx } from '../utils'; + +export interface LinearUnitLabelElement extends SVGTextElement {} + +export interface LinearUnitLabelProps + extends LinearUnitLabelParams, + WithOverridableCtx>, + Omit< + React.SVGTextElementAttributes, + 'offset' | 'dominantBaseline' + > { + children?: React.ReactNode; +} diff --git a/packages/dataviz/src/LinearUnitLabel/index.ts b/packages/dataviz/src/LinearUnitLabel/index.ts new file mode 100644 index 00000000..a775ce63 --- /dev/null +++ b/packages/dataviz/src/LinearUnitLabel/index.ts @@ -0,0 +1,2 @@ +export * from './LinearUnitLabel'; +export * from './LinearUnitLabelProps'; diff --git a/packages/dataviz/src/LinearUnits/LinearUnits.stories.tsx b/packages/dataviz/src/LinearUnits/LinearUnits.stories.tsx new file mode 100644 index 00000000..614dba06 --- /dev/null +++ b/packages/dataviz/src/LinearUnits/LinearUnits.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { LinearUnitLabel } from '../LinearUnitLabel'; +import { LinearUnits } from './LinearUnits'; + +const meta: Meta = { + title: 'LinearUnits', + component: LinearUnits, + decorators: (Story) => ( + + + + ), + argTypes: { + offset: { + control: { type: 'range', min: -20, max: 20, step: 1 }, + }, + }, + args: { + children: [ + + Label + , + + Label + , + + Label + , + ], + ctx: { + linearScale: { + valueMin: 0, + valueMax: 100, + lengthMin: 0, + lengthMax: 200, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: '(default)', + args: {}, +}; + +export const Offset4: Story = { + name: 'offset=4', + args: { + offset: 4, + }, +}; + +export const OffsetNeg4: Story = { + name: 'offset=-4', + args: { + offset: -4, + }, +}; diff --git a/packages/dataviz/src/LinearUnits/LinearUnits.tsx b/packages/dataviz/src/LinearUnits/LinearUnits.tsx new file mode 100644 index 00000000..5da0999a --- /dev/null +++ b/packages/dataviz/src/LinearUnits/LinearUnits.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { LinearUnitsChildProps, LinearUnitsProps } from './LinearUnitsProps'; +import { mergeCtxOverrides } from '../utils'; + +export const LinearUnits = (props: LinearUnitsProps) => { + const { + children, + dy, + dominantBaseline, + offset, + ctx: ctxProp, + overrides, + } = props; + + if (ctxProp === undefined) { + throw Error( + 'Oops! `LinearUnits` received `ctx: undefined`. Did you mean to either (1) render as a child of `LinearScale`? or (2) specify `ctx` explicitly?' + ); + } + + const ctxWithOverrides = mergeCtxOverrides(ctxProp, overrides); + + const ctx = { + ...ctxWithOverrides, + linearUnits: { + dy, + dominantBaseline, + offset, + }, + }; + + return React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + ctx: child.props.ctx ?? ctx, + }); + } + + return child; + }); +}; diff --git a/packages/dataviz/src/LinearUnits/LinearUnitsProps.ts b/packages/dataviz/src/LinearUnits/LinearUnitsProps.ts new file mode 100644 index 00000000..0767a7ab --- /dev/null +++ b/packages/dataviz/src/LinearUnits/LinearUnitsProps.ts @@ -0,0 +1,17 @@ +import React from 'react'; +import { + LinearCtx, + LinearUnitsParams, + WithCtx, + WithOverridableCtx, +} from '../utils'; + +export interface LinearUnitsProps + extends LinearUnitsParams, + WithOverridableCtx> { + children?: React.ReactNode; +} + +export type LinearUnitsChildProps = WithCtx< + Pick +>; diff --git a/packages/dataviz/src/LinearUnits/index.ts b/packages/dataviz/src/LinearUnits/index.ts new file mode 100644 index 00000000..acc31530 --- /dev/null +++ b/packages/dataviz/src/LinearUnits/index.ts @@ -0,0 +1,2 @@ +export * from './LinearUnits'; +export * from './LinearUnitsProps'; diff --git a/packages/dataviz/src/index.ts b/packages/dataviz/src/index.ts index 9d0374a6..0a5de7b2 100644 --- a/packages/dataviz/src/index.ts +++ b/packages/dataviz/src/index.ts @@ -5,6 +5,9 @@ export * from './BarScale'; export * from './BarUnits'; export * from './BarUnitLabel'; export * from './Chart'; +export * from './LinearScale'; +export * from './LinearUnitLabel'; +export * from './LinearUnits'; export * from './RadialBar'; export * from './RadialBarCircle'; export * from './RadialBarScale'; diff --git a/packages/dataviz/src/utils/index.ts b/packages/dataviz/src/utils/index.ts index 66c982f7..1299a79e 100644 --- a/packages/dataviz/src/utils/index.ts +++ b/packages/dataviz/src/utils/index.ts @@ -10,6 +10,10 @@ export * from './chart'; export * from './circle'; export * from './coordinates'; export * from './epsilon'; +export * from './linear-ctx'; +export * from './linear-scale'; +export * from './linear-unit-label'; +export * from './linear-units'; export * from './margin'; export * from './path'; export * from './radial-bar-circle'; diff --git a/packages/dataviz/src/utils/linear-ctx.ts b/packages/dataviz/src/utils/linear-ctx.ts new file mode 100644 index 00000000..01af3545 --- /dev/null +++ b/packages/dataviz/src/utils/linear-ctx.ts @@ -0,0 +1,13 @@ +import { LinearScaleParams } from './linear-scale'; +import { LinearUnitsParams } from './linear-units'; + +export type LinearCtx = { + /** + * The parameters of the linear scale. + */ + linearScale: LinearScaleParams; + /** + * The parameters of the linear units. + */ + linearUnits: LinearUnitsParams; +}; diff --git a/packages/dataviz/src/utils/linear-scale.ts b/packages/dataviz/src/utils/linear-scale.ts new file mode 100644 index 00000000..a36b4887 --- /dev/null +++ b/packages/dataviz/src/utils/linear-scale.ts @@ -0,0 +1,26 @@ +export type LinearScaleParams = { + /** + * The minimum value of the data. + */ + valueMin: number; + /** + * The maximum value of the data. + */ + valueMax: number; + /** + * The minimum distance, in pixels, between the start and end of the line. Defaults to 0. + */ + lengthMin?: number; + /** + * The maximum distance, in pixels, between the start and end of the line. + */ + lengthMax: number; +}; + +export function evalLinearScaleLengthMin( + param: LinearScaleParams['lengthMin'] +) { + return param ?? DEFAULT_LINEAR_SCALE_LENGTH_MIN; +} + +export const DEFAULT_LINEAR_SCALE_LENGTH_MIN = 0; diff --git a/packages/dataviz/src/utils/linear-unit-label.ts b/packages/dataviz/src/utils/linear-unit-label.ts new file mode 100644 index 00000000..27eced23 --- /dev/null +++ b/packages/dataviz/src/utils/linear-unit-label.ts @@ -0,0 +1,80 @@ +import { Property, SvgProperties } from 'csstype'; +import * as D3Scale from 'd3-scale'; +import { LinearScaleParams, evalLinearScaleLengthMin } from './linear-scale'; +import { LinearUnitsParams } from './linear-units'; + +export type LinearUnitLabelParams = { + /** + * The value at which the unit label is located. + */ + at: number; + /** + * The baseline used to align text and inline-level contents of the unit label. + */ + dominantBaseline?: Property.DominantBaseline; + /** + * The offset of the unit label below (positive value) or above (negative value) the line. + */ + offset?: number; +}; + +export type DrawLinearUnitLabelParams = { + scale: LinearScaleParams; + units: LinearUnitsParams; + unitLabel: LinearUnitLabelParams; +}; + +export function drawLinearUnitLabel(params: DrawLinearUnitLabelParams) { + const lengthMin = evalLinearScaleLengthMin(params.scale.lengthMin); + + const scale = D3Scale.scaleLinear() + .domain([params.scale.valueMin, params.scale.valueMax]) + .range([lengthMin, params.scale.lengthMax]); + + const value = params.unitLabel.at; + + const length = scale(value); + + const offset = evalLinearUnitLabelOffset( + params.unitLabel.offset ?? params.units.offset + ); + + const x = length; + const y = offset; + + const textAnchor: SvgProperties['textAnchor'] = + x === lengthMin ? 'start' : x === params.scale.lengthMax ? 'end' : 'middle'; + + const dominantBaseline: SvgProperties['dominantBaseline'] = + evalLinearUnitLabelDominantBaseline( + params.unitLabel.dominantBaseline ?? params.units.dominantBaseline + ); + + return { + x, + y, + textAnchor, + dominantBaseline, + }; +} + +export type LinearUnitLabelOffset = number; + +const DEFAULT_LINEAR_UNIT_LABEL_OFFSET: LinearUnitLabelOffset = 0; + +export function evalLinearUnitLabelOffset( + param: LinearUnitLabelParams['offset'] +): LinearUnitLabelOffset { + return param ?? DEFAULT_LINEAR_UNIT_LABEL_OFFSET; +} + +export type LinearUnitLabelDominantBaseline = Property.DominantBaseline; + +const DEFAULT_LINEAR_UNIT_LABEL_DOMINANT_BASELINE: LinearUnitLabelDominantBaseline = + 'text-before-edge'; + +export function evalLinearUnitLabelDominantBaseline( + param: LinearUnitLabelParams['dominantBaseline'] +): LinearUnitLabelDominantBaseline { + return param ?? DEFAULT_LINEAR_UNIT_LABEL_DOMINANT_BASELINE; +} diff --git a/packages/dataviz/src/utils/linear-units.ts b/packages/dataviz/src/utils/linear-units.ts new file mode 100644 index 00000000..49ad9998 --- /dev/null +++ b/packages/dataviz/src/utils/linear-units.ts @@ -0,0 +1,16 @@ +import { Property } from 'csstype'; + +export type LinearUnitsParams = { + /** + * The baseline used to align text and inline-level contents of the units. + */ + dominantBaseline?: Property.DominantBaseline; + /** + * The shift along the y-axis of the units. + */ + dy?: number; + /** + * The offset of the units below (positive value) or above (negative value) the line. + */ + offset?: number; +};