Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/angular/src/components/radial-bar/radial-bar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export class VisRadialBarComponent<Datum> implements RadialBarConfigInterface<Da
/** Accessor function for getting the unique data record id. Used for more persistent data updates. Default: `(d, i) => d.id ?? i` */
@Input() id?: ((d: Datum, i: number, ...rest) => string | number)

/** Value accessor function. Default: `undefined` */
/** Value accessor function. Returning `null` or `undefined` marks the value as missing,
* and the corresponding bar will not be rendered. Default: `undefined` */
@Input() value: NumericAccessor<Datum>

/** Maximum value accessor or an array of maximums (indexed by each datum's original position in `data` before sorting).
Expand All @@ -77,6 +78,12 @@ export class VisRadialBarComponent<Datum> implements RadialBarConfigInterface<Da
/** Pad angle in radians applied between the bar and its end. Default: `0` */
@Input() padAngle?: number

/** Minimum bar angle in radians. Bars with small values, `0` included, will be extended to that angle,
* so that they remain visible. The value is clamped to the length of `angleRange`.
* Bars with missing values (see `value`) are not affected: they're never rendered.
* Set it to `0` to disable. Default: `0.01` (about 1 pixel wide on a ring of a 100 pixel radius) */
@Input() barMinAngle?: number

/** Custom sort function. Default: `undefined` */
@Input() sortFunction?: (a: Datum, b: Datum) => number

Expand Down Expand Up @@ -141,8 +148,8 @@ export class VisRadialBarComponent<Datum> implements RadialBarConfigInterface<Da
}

private getConfig (): RadialBarConfigInterface<Datum> {
const { duration, events, attributes, id, value, maxValue, angleRange, padAngle, sortFunction, cornerRadius, color, radius, trackWidth, trackPadding, reverseOrder, centralLabel, centralSubLabel, centralSubLabelWrap, centralLabelOffsetX, centralLabelOffsetY, showBackground, backgroundAngleRange } = this
const config = { duration, events, attributes, id, value, maxValue, angleRange, padAngle, sortFunction, cornerRadius, color, radius, trackWidth, trackPadding, reverseOrder, centralLabel, centralSubLabel, centralSubLabelWrap, centralLabelOffsetX, centralLabelOffsetY, showBackground, backgroundAngleRange }
const { duration, events, attributes, id, value, maxValue, angleRange, padAngle, barMinAngle, sortFunction, cornerRadius, color, radius, trackWidth, trackPadding, reverseOrder, centralLabel, centralSubLabel, centralSubLabelWrap, centralLabelOffsetX, centralLabelOffsetY, showBackground, backgroundAngleRange } = this
const config = { duration, events, attributes, id, value, maxValue, angleRange, padAngle, barMinAngle, sortFunction, cornerRadius, color, radius, trackWidth, trackPadding, reverseOrder, centralLabel, centralSubLabel, centralSubLabelWrap, centralLabelOffsetX, centralLabelOffsetY, showBackground, backgroundAngleRange }
const keys = Object.keys(config) as (keyof RadialBarConfigInterface<Datum>)[]
keys.forEach(key => { if (config[key] === undefined) delete config[key] })

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react'
import { VisSingleContainer, VisRadialBar } from '@unovis/react'
import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index'

export const title = 'Radial Bar Min Angle'
export const subTitle = 'Keeping bars with tiny and zero values visible'

type DataRecord = { key: string; value: number | null }

const data: DataRecord[] = [
{ key: 'Errors', value: 0.05 },
{ key: 'Warnings', value: 12 },
{ key: 'Info', value: 64 },
{ key: 'Debug', value: 0 },
{ key: 'Trace', value: null }, // Missing data: the bar is not rendered
]

const cases: {
label: string;
barMinAngle: number;
angleRange?: [number, number];
padAngle?: number;
}[] = [
{ label: 'barMinAngle: 0 (disabled)', barMinAngle: 0 },
{ label: 'barMinAngle: 0.01 (default)', barMinAngle: 0.01 },
{ label: 'barMinAngle: 0.2', barMinAngle: 0.2 },
{ label: 'barMinAngle: 0.2, padAngle: 0.05', barMinAngle: 0.2, padAngle: 0.05 },
{ label: 'barMinAngle: 0.2, top half', barMinAngle: 0.2, angleRange: [-Math.PI / 2, Math.PI / 2] },
{ label: 'barMinAngle: 0.2, reversed range', barMinAngle: 0.2, angleRange: [0, -2 * Math.PI] },
{ label: 'barMinAngle: 10 → clamped to 2π', barMinAngle: 10 },
]

export const component = (props: ExampleViewerDurationProps): React.ReactNode => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 16, width: '100%' }}>
{cases.map(c => (
<div key={c.label} style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: 8, minWidth: 0 }}>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 4 }}>{c.label}</div>
<VisSingleContainer height={240}>
<VisRadialBar<DataRecord>
value={d => d.value}
maxValue={100}
data={data}
duration={props.duration}
angleRange={c.angleRange}
padAngle={c.padAngle}
barMinAngle={c.barMinAngle}
trackWidth={14}
trackPadding={4}
cornerRadius={7}
/>
</VisSingleContainer>
</div>
))}
</div>
)
14 changes: 12 additions & 2 deletions packages/ts/src/components/radial-bar/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { ColorAccessor, NumericAccessor } from '@/types/accessor'
export interface RadialBarConfigInterface<Datum> extends ComponentConfigInterface {
/** Accessor function for getting the unique data record id. Used for more persistent data updates. Default: `(d, i) => d.id ?? i` */
id?: ((d: Datum, i: number, ...any: unknown[]) => string | number);
/** Value accessor function. Default: `undefined` */
/** Value accessor function. Returning `null` or `undefined` marks the value as missing,
* and the corresponding bar will not be rendered. Default: `undefined`
*/
value: NumericAccessor<Datum>;
/** Maximum value accessor or an array of maximums (indexed by each datum's original position in `data` before sorting).
* Used to scale each bar's arc length: each bar fills `(value / maxValue) * (angleRange[1] - angleRange[0])`.
Expand All @@ -18,6 +20,12 @@ export interface RadialBarConfigInterface<Datum> extends ComponentConfigInterfac
angleRange?: [number, number];
/** Pad angle in radians applied between the bar and its end. Default: `0` */
padAngle?: number;
/** Minimum bar angle in radians. Bars with small values, `0` included, will be extended to that angle,
* so that they remain visible. The value is clamped to the length of `angleRange`.
* Bars with missing values (see `value`) are not affected: they're never rendered.
* Set it to `0` to disable. Default: `0.01` (about 1 pixel wide on a ring of a 100 pixel radius)
*/
barMinAngle?: number;
/** Custom sort function. Default: `undefined` */
sortFunction?: (a: Datum, b: Datum) => number;
/** Corner Radius. Default: `0` */
Expand Down Expand Up @@ -53,11 +61,13 @@ export interface RadialBarConfigInterface<Datum> extends ComponentConfigInterfac

export const RadialBarDefaultConfig: RadialBarConfigInterface<unknown> = {
...ComponentDefaultConfig,
id: (d: unknown, i: number): string | number => (d as { id: string }).id ?? i,
// Optional chaining because a data record itself can be `null` when the data has gaps
id: (d: unknown, i: number): string | number => (d as { id: string })?.id ?? i,
value: undefined,
maxValue: undefined,
angleRange: [0, 2 * Math.PI],
padAngle: 0,
barMinAngle: 0.01,
sortFunction: undefined,
cornerRadius: 0,
color: undefined,
Expand Down
20 changes: 16 additions & 4 deletions packages/ts/src/components/radial-bar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export class RadialBar<Datum> extends ComponentCore<Datum[], RadialBarConfigInte
const endAngle = config.angleRange?.[1] ?? 2 * Math.PI
const angleRange = endAngle - startAngle

// Angle ranges can be reversed (e.g. `[Math.PI, 0]`), so we keep the sweep direction separate from its length
const angleDirection = angleRange < 0 ? -1 : 1
const barMinAngle = clamp(config.barMinAngle ?? 0, 0, Math.abs(angleRange))

const unitBBox = getArcUnitBoundingBox(startAngle, endAngle)
const bboxW = unitBBox.xMax - unitBBox.xMin
const bboxH = unitBBox.yMax - unitBBox.yMin
Expand All @@ -109,8 +113,12 @@ export class RadialBar<Datum> extends ComponentCore<Datum[], RadialBarConfigInte
? clamp(ringStride - trackPadding, 1, ringStride)
: trackWidth

// Resolve per-bar value and max
const values = wrapped.map((d) => getNumber(d.datum, config.value, d.index) ?? 0)
// Resolve per-bar value and max. `null` and `undefined` are treated as missing data:
// such bars are not rendered at all, unlike `0` values, which still get `barMinAngle`.
const values = wrapped.map((d) => {
const value = getNumber(d.datum, config.value, d.index)
return isNumber(value) && isFinite(value) ? value : null
})
const dataMax = max(values) ?? 0
const maxValues = wrapped.map((d) => {
const mv = config.maxValue
Expand All @@ -130,15 +138,19 @@ export class RadialBar<Datum> extends ComponentCore<Datum[], RadialBarConfigInte

const value = values[i]
const perMax = maxValues[i]
const fraction = clamp(value / perMax, 0, 1)
const fraction = value === null ? 0 : clamp(value / perMax, 0, 1)

// Bars with small values (including `0`) are extended to `barMinAngle` to stay visible.
// Bars with missing values are left collapsed and hidden (see `setOpacity` in `modules/bar`).
const sweepAngle = value === null ? 0 : Math.max(Math.abs(fraction * angleRange), barMinAngle)

return {
data: d.datum,
index: d.index,
ringIndex,
value,
startAngle,
endAngle: startAngle + fraction * angleRange,
endAngle: startAngle + angleDirection * sweepAngle,
innerRadius: inner,
outerRadius: outer,
padAngle: config.padAngle,
Expand Down
3 changes: 2 additions & 1 deletion packages/ts/src/components/radial-bar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export interface RadialBarArcDatum<Datum> {
index: number;
/** Index of the ring counted from the outermost (`0` = outermost). */
ringIndex: number;
value: number;
/** Resolved value. `null` when the `value` accessor returned `null`, `undefined` or a non-finite number */
value: number | null;
startAngle: number;
endAngle: number;
innerRadius: number;
Expand Down
18 changes: 18 additions & 0 deletions packages/website/docs/misc/RadialBar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ partial range — useful for semicircular or gauge-style charts.
For partial arcs the chart's origin lands on the bbox edge of the visible arc, so you may want to combine `centralLabelOffsetY` with the `--vis-radial-bar-central-label-text-anchor` / `--vis-radial-bar-central-sub-label-text-anchor` CSS variables (`start` / `middle` / `end`) to align the labels toward the side the arc opens to.
<DocWrapper {...radialBarProps()} angleRange={[-Math.PI / 2, Math.PI / 2]} centralLabel="Gauge" centralLabelOffsetY={-15}/>

## Minimum Bar Angle
Bars representing very small values can become too short to notice. `barMinAngle` sets the minimum angular extent
of a bar in radians, so that such bars remain visible. It defaults to `0.01`, which is about one pixel wide on a
ring of a 100 pixel radius — increase it to make small values more prominent, or set it to `0` to disable.
The value is clamped to the length of `angleRange`.

`0` counts as a value, so zero bars are rendered with the minimum angle too. Missing data is different: when the
`value` accessor returns `null` or `undefined`, the bar is not rendered at all and only its background track remains.
In the example below the rings are, from the outside in: a tiny value, a regular value, `0`, and `null`:
<InputWrapper {...radialBarProps()}
data={[0.0005, 0.35, 0, null]}
maxValue={1}
property="barMinAngle"
defaultValue={0.15}
inputType="range"
inputProps={{ min: 0, max: 1, step: 0.01 }}
/>

## Stacking Order
By default `data[0]` is the **outermost** ring and subsequent data render inward. Set `reverseOrder` to `true`
to flip the order:
Expand Down