progressRing.tsx 4.1 KB

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