diff --git a/packages/dev/src/examples/misc/donut/half-donut-full-height/index.tsx b/packages/dev/src/examples/misc/donut/half-donut-full-height/index.tsx new file mode 100644 index 000000000..3928a5220 --- /dev/null +++ b/packages/dev/src/examples/misc/donut/half-donut-full-height/index.tsx @@ -0,0 +1,37 @@ +import React, { useEffect, useState } from 'react' +import { VisSingleContainer, VisDonut } from '@unovis/react' +import { DONUT_HALF_ANGLE_RANGES } from '@unovis/ts' + +export const title = 'Half Donut: Full Height' +export const subTitle = 'Testing the resize behavior' +export const component = (): JSX.Element => { + const data = [3, 2, 5, 4, 0, 1] + + const [currentAngleRange, setCurrentAngleRange] = useState(DONUT_HALF_ANGLE_RANGES[0]) + + // Cycle through the half-donut angle ranges for testing + useEffect(() => { + const interval = setInterval(() => { + setCurrentAngleRange((prev: [number, number]) => { + const currentIndex = DONUT_HALF_ANGLE_RANGES.indexOf(prev) + return DONUT_HALF_ANGLE_RANGES[(currentIndex + 1) % DONUT_HALF_ANGLE_RANGES.length] + }) + }, 2000) + return () => clearInterval(interval) + }, []) + + return ( + + d} + data={data} + padAngle={0.02} + duration={0} + arcWidth={80} + angleRange={currentAngleRange} + centralLabel="Central Label" + centralSubLabel="Sub-label" + /> + + ) +} diff --git a/packages/ts/src/components.ts b/packages/ts/src/components.ts index 24a8812eb..e45878f83 100644 --- a/packages/ts/src/components.ts +++ b/packages/ts/src/components.ts @@ -25,7 +25,14 @@ export { LeafletFlowMap } from './components/leaflet-flow-map' export { ChordDiagram } from './components/chord-diagram' export { Graph } from './components/graph' export { VisControls } from './components/vis-controls' -export { Donut } from './components/donut' +export { + Donut, + DONUT_HALF_ANGLE_RANGE_TOP, + DONUT_HALF_ANGLE_RANGE_RIGHT, + DONUT_HALF_ANGLE_RANGE_BOTTOM, + DONUT_HALF_ANGLE_RANGE_LEFT, + DONUT_HALF_ANGLE_RANGES, +} from './components/donut' export { FreeBrush } from './components/free-brush' export { XYLabels } from './components/xy-labels' export { NestedDonut } from './components/nested-donut' diff --git a/packages/ts/src/components/donut/config.ts b/packages/ts/src/components/donut/config.ts index ed3b5e807..538692e42 100644 --- a/packages/ts/src/components/donut/config.ts +++ b/packages/ts/src/components/donut/config.ts @@ -42,6 +42,12 @@ export interface DonutConfigInterface extends ComponentConfigInterface { showBackground?: boolean; /** Background angle range. When undefined, the value will be taken from `angleRange`. Default: `undefined` */ backgroundAngleRange?: [number, number]; + + /** Vertical offset of the half-donut label. */ + halfDonutLabelOffsetY?: number; + + /** Horizontal offset of the half-donut label. */ + halfDonutLabelOffsetX?: number; } export const DonutDefaultConfig: DonutConfigInterface = { diff --git a/packages/ts/src/components/donut/index.ts b/packages/ts/src/components/donut/index.ts index 860668f5b..d9a5cc4c9 100644 --- a/packages/ts/src/components/donut/index.ts +++ b/packages/ts/src/components/donut/index.ts @@ -25,6 +25,22 @@ import { createArc, updateArc, removeArc } from './modules/arc' // Styles import * as s from './style' +// Constants that support half donuts +export const DONUT_HALF_ANGLE_RANGES = Array.from({ length: 4 }, (_, i): [number, number] => { + const offset = -Math.PI / 2 + i * Math.PI / 2 + return [offset, offset + Math.PI] +}) + +export const [ + DONUT_HALF_ANGLE_RANGE_TOP, + DONUT_HALF_ANGLE_RANGE_RIGHT, + DONUT_HALF_ANGLE_RANGE_BOTTOM, + DONUT_HALF_ANGLE_RANGE_LEFT, +] = DONUT_HALF_ANGLE_RANGES + +const DONUT_HALF_LABEL_OFFSET_Y_DEFAULT = 20 +const DONUT_HALF_LABEL_OFFSET_X_DEFAULT = 60 + export class Donut extends ComponentCore> { static selectors = s protected _defaultConfig = DonutDefaultConfig as DonutConfigInterface @@ -68,9 +84,46 @@ export class Donut extends ComponentCore config.showEmptySegments || getNumber(d.datum, config.value, d.index)) const duration = isNumber(customDuration) ? customDuration : config.duration - const outerRadius = config.radius || Math.min(this._width - bleed.left - bleed.right, this._height - bleed.top - bleed.bottom) / 2 + + // Handle half-donut cases, which adjust the scaling and positioning. + // One of these is true if we are dealing with a half-donut. + const [ + isHalfDonutTop, + isHalfDonutRight, + isHalfDonutBottom, + isHalfDonutLeft, + ] = DONUT_HALF_ANGLE_RANGES.map(angleRange => + config.angleRange && ( + config.angleRange[0] === angleRange[0] && + config.angleRange[1] === angleRange[1] + ) + ) + const isVerticalHalfDonut = isHalfDonutTop || isHalfDonutBottom + const isHorizontalHalfDonut = isHalfDonutRight || isHalfDonutLeft + + // Compute the bounding box of the donut, + // considering it may be a half-donut + const width = this._width * (isHorizontalHalfDonut ? 2 : 1) + const height = this._height * (isVerticalHalfDonut ? 2 : 1) + + const outerRadius = config.radius || Math.min(width - bleed.left - bleed.right, height - bleed.top - bleed.bottom) / 2 const innerRadius = config.arcWidth === 0 ? 0 : clamp(outerRadius - config.arcWidth, 0, outerRadius - 1) + const translateY = this._height / 2 + (isHalfDonutTop ? outerRadius / 2 : isHalfDonutBottom ? -outerRadius / 2 : 0) + const translateX = this._width / 2 + (isHalfDonutLeft ? outerRadius / 2 : isHalfDonutRight ? -outerRadius / 2 : 0) + const translate = `translate(${translateX},${translateY})` + + const { + halfDonutLabelOffsetY = DONUT_HALF_LABEL_OFFSET_Y_DEFAULT, + halfDonutLabelOffsetX = DONUT_HALF_LABEL_OFFSET_X_DEFAULT, + } = config + + const labelTranslateY = isHalfDonutTop ? -halfDonutLabelOffsetY : isHalfDonutBottom ? halfDonutLabelOffsetY : 0 + const labelTranslateX = isHalfDonutLeft ? -halfDonutLabelOffsetX : isHalfDonutRight ? halfDonutLabelOffsetX : 0 + const labelTranslate = `translate(${translateX + labelTranslateX},${translateY + labelTranslateY})` + + this.arcGroup.attr('transform', translate) + this.arcGen .startAngle(d => d.startAngle) .endAngle(d => d.endAngle) @@ -86,8 +139,6 @@ export class Donut extends ComponentCore getNumber(d.datum, config.value, d.index) || 0) .sort((a, b) => config.sortFunction?.(a.datum, b.datum)) - this.arcGroup.attr('transform', `translate(${this._width / 2},${this._height / 2})`) - const arcData: DonutArcDatum[] = pieGen(data).map(d => { const arc = { ...d, @@ -123,12 +174,12 @@ export class Donut extends ComponentCore extends ComponentCore