progressRing.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import {SerializedStyles} from '@emotion/react';
  2. import styled from '@emotion/styled';
  3. import {AnimatePresence, motion} from 'framer-motion';
  4. import testableTransition from 'sentry/utils/testableTransition';
  5. import theme, {Theme} from 'sentry/utils/theme';
  6. type TextProps = {
  7. percent: number;
  8. theme: Theme;
  9. textCss?: Props['textCss'];
  10. };
  11. type Props = React.HTMLAttributes<SVGSVGElement> & {
  12. value: number;
  13. /**
  14. * Apply a micro animation when the text value changes
  15. */
  16. animateText?: boolean;
  17. /**
  18. * The color of the ring background
  19. */
  20. backgroundColor?: string;
  21. /**
  22. * The width of the progress ring bar
  23. */
  24. barWidth?: number;
  25. maxValue?: number;
  26. minValue?: number;
  27. /**
  28. * The color of the ring bar. A function may be provided to compute the color
  29. * based on the percent value filled of the progress bar.
  30. */
  31. progressColor?: string;
  32. /**
  33. * Endcaps on the progress bar
  34. */
  35. progressEndcaps?: React.SVGAttributes<SVGCircleElement>['strokeLinecap'];
  36. size?: number;
  37. /**
  38. * Text to display in the center of the ring
  39. */
  40. text?: React.ReactNode;
  41. /**
  42. * The css to apply to the center text. A function may be provided to compute
  43. * styles based on the state of the progress bar.
  44. */
  45. textCss?: (p: TextProps) => SerializedStyles;
  46. };
  47. const Text = styled('div')<Omit<TextProps, 'theme'>>`
  48. position: absolute;
  49. display: flex;
  50. align-items: center;
  51. justify-content: center;
  52. height: 100%;
  53. width: 100%;
  54. color: ${p => p.theme.chartLabel};
  55. font-size: ${p => p.theme.fontSizeExtraSmall};
  56. transition: color 100ms;
  57. ${p => p.textCss && p.textCss(p)}
  58. `;
  59. const AnimatedText = motion(Text);
  60. AnimatedText.defaultProps = {
  61. initial: {opacity: 0, y: -10},
  62. animate: {opacity: 1, y: 0},
  63. exit: {opacity: 0, y: 10},
  64. transition: testableTransition(),
  65. };
  66. const ProgressRing = ({
  67. value,
  68. minValue = 0,
  69. maxValue = 100,
  70. size = 20,
  71. barWidth = 3,
  72. text,
  73. textCss,
  74. animateText = false,
  75. progressColor = theme.green300,
  76. backgroundColor = theme.gray200,
  77. progressEndcaps,
  78. ...p
  79. }: Props) => {
  80. const radius = size / 2 - barWidth / 2;
  81. const circumference = 2 * Math.PI * radius;
  82. const boundedValue = Math.min(Math.max(value, minValue), maxValue);
  83. const progress = (boundedValue - minValue) / (maxValue - minValue);
  84. const percent = progress * 100;
  85. const progressOffset = (1 - progress) * circumference;
  86. const TextComponent = animateText ? AnimatedText : Text;
  87. let textNode = (
  88. <TextComponent key={text?.toString()} {...{textCss, percent}}>
  89. {text}
  90. </TextComponent>
  91. );
  92. textNode = animateText ? (
  93. <AnimatePresence initial={false}>{textNode}</AnimatePresence>
  94. ) : (
  95. textNode
  96. );
  97. return (
  98. <RingSvg
  99. role="img"
  100. height={radius * 2 + barWidth}
  101. width={radius * 2 + barWidth}
  102. {...p}
  103. >
  104. <RingBackground
  105. r={radius}
  106. barWidth={barWidth}
  107. cx={radius + barWidth / 2}
  108. cy={radius + barWidth / 2}
  109. color={backgroundColor}
  110. />
  111. <RingBar
  112. strokeDashoffset={progressOffset}
  113. strokeLinecap={progressEndcaps}
  114. circumference={circumference}
  115. r={radius}
  116. barWidth={barWidth}
  117. cx={radius + barWidth / 2}
  118. cy={radius + barWidth / 2}
  119. color={progressColor}
  120. />
  121. <foreignObject height="100%" width="100%">
  122. {text !== undefined && textNode}
  123. </foreignObject>
  124. </RingSvg>
  125. );
  126. };
  127. const RingSvg = styled('svg')`
  128. position: relative;
  129. `;
  130. const RingBackground = styled('circle')<{barWidth: number; color: string}>`
  131. fill: none;
  132. stroke: ${p => p.color};
  133. stroke-width: ${p => p.barWidth}px;
  134. transition: stroke 100ms;
  135. `;
  136. const RingBar = styled('circle')<{
  137. barWidth: number;
  138. circumference: number;
  139. color: string;
  140. }>`
  141. fill: none;
  142. stroke: ${p => p.color};
  143. stroke-width: ${p => p.barWidth}px;
  144. stroke-dasharray: ${p => p.circumference} ${p => p.circumference};
  145. transform: rotate(-90deg);
  146. transform-origin: 50% 50%;
  147. transition: stroke-dashoffset 200ms, stroke 100ms;
  148. `;
  149. export default ProgressRing;
  150. // We export components to allow for css selectors
  151. export {RingBackground, RingBar, Text as RingText};