From db4a823327dc7db58c07f79b3d9699dace46bb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Thu, 24 Aug 2023 16:37:57 +0800 Subject: [PATCH] feat: Circle support conic color (#253) * docs: update demo * chore: basic color * feat: support conic * feat: support conic * test: update snapshot --- docs/examples/gap.tsx | 28 ++++- docs/examples/gradient-circle.tsx | 15 +++ package.json | 1 + src/Circle/ColorGradient.tsx | 26 +++++ src/Circle/PtgCircle.tsx | 102 +++++++++++++++++++ src/{Circle.tsx => Circle/index.tsx} | 130 ++++++++---------------- src/Circle/util.ts | 57 +++++++++++ src/interface.ts | 6 +- tests/__snapshots__/conic.spec.tsx.snap | 95 +++++++++++++++++ tests/__snapshots__/index.spec.js.snap | 112 ++++++++++---------- tests/conic.spec.tsx | 42 ++++++++ 11 files changed, 466 insertions(+), 148 deletions(-) create mode 100644 src/Circle/ColorGradient.tsx create mode 100644 src/Circle/PtgCircle.tsx rename src/{Circle.tsx => Circle/index.tsx} (58%) create mode 100644 src/Circle/util.ts create mode 100644 tests/__snapshots__/conic.spec.tsx.snap create mode 100644 tests/conic.spec.tsx diff --git a/docs/examples/gap.tsx b/docs/examples/gap.tsx index c4f53b1..239b1b6 100644 --- a/docs/examples/gap.tsx +++ b/docs/examples/gap.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Circle, ProgressProps } from 'rc-progress'; +import { Circle, type ProgressProps } from 'rc-progress'; const colorMap = ['#3FC7FA', '#85D262', '#FE8C6A', '#FF5959', '#BC3FFA']; @@ -11,7 +11,7 @@ class Example extends React.Component { constructor(props) { super(props); this.state = { - percent: 30, + percent: 100, colorIndex: 0, subPathsCount: 3, }; @@ -103,6 +103,30 @@ class Example extends React.Component { strokeColor={color} /> +
+ +
+
+ +
); } diff --git a/docs/examples/gradient-circle.tsx b/docs/examples/gradient-circle.tsx index 58762d0..3d3bb59 100644 --- a/docs/examples/gradient-circle.tsx +++ b/docs/examples/gradient-circle.tsx @@ -34,6 +34,7 @@ const Example = () => { }} /> +

Circle With Success Percent {65}%

{ ]} />
+ +

Circle colors

+
+ +
); }; diff --git a/package.json b/package.json index a62c137..7c37931 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "rc-util": "^5.16.1" }, "devDependencies": { + "@testing-library/react": "^12.1.5", "@types/classnames": "^2.2.9", "@types/jest": "^27.5.0", "@types/keyv": "3.1.4", diff --git a/src/Circle/ColorGradient.tsx b/src/Circle/ColorGradient.tsx new file mode 100644 index 0000000..28352b5 --- /dev/null +++ b/src/Circle/ColorGradient.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +function stripPercentToNumber(percent: string) { + return +percent.replace('%', ''); +} + +export interface ColorGradientProps { + gradientId: string; + gradient?: Record; +} + +export default function ColorGradient(props: ColorGradientProps) { + const { gradientId, gradient } = props; + + return ( + + + {Object.keys(gradient) + .sort((a, b) => stripPercentToNumber(a) - stripPercentToNumber(b)) + .map((key, index) => ( + + ))} + + + ); +} diff --git a/src/Circle/PtgCircle.tsx b/src/Circle/PtgCircle.tsx new file mode 100644 index 0000000..e13fb4a --- /dev/null +++ b/src/Circle/PtgCircle.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import type { ProgressProps } from '..'; +import type { StrokeColorType } from '../interface'; + +export interface ColorGradientProps { + prefixCls: string; + gradientId: string; + style: React.CSSProperties; + ptg: number; + radius: number; + strokeLinecap: ProgressProps['strokeLinecap']; + strokeWidth: ProgressProps['strokeWidth']; + size: number; + color: StrokeColorType; + conic: boolean; + gapDegree: number; +} + +const PtgCircle = React.forwardRef((props, ref) => { + const { + prefixCls, + color, + gradientId, + radius, + style: circleStyleForStack, + ptg, + strokeLinecap, + strokeWidth, + size, + conic, + gapDegree, + } = props; + + const isGradient = color && typeof color === 'object'; + + const stroke = React.useMemo(() => { + if (conic) { + return '#FFF'; + } + + return isGradient ? `url(#${gradientId})` : undefined; + }, [gradientId, isGradient, conic]); + + // ========================== Circle ========================== + const halfSize = size / 2; + + const circleNode = ( + + ); + + // ========================== Render ========================== + if (!conic) { + return circleNode; + } + + const maskId = `${gradientId}-conic`; + const conicColorKeys = Object.keys(color).filter((key) => key !== 'conic'); + + const fromDeg = gapDegree ? `${180 + gapDegree / 2}deg` : '0deg'; + + const conicColors = conicColorKeys.map((key) => { + const parsedKey = parseFloat(key); + const ptgKey = `${gapDegree ? Math.floor((parsedKey * (360 - gapDegree)) / 360) : parsedKey}%`; + + return `${color[key]} ${ptgKey}`; + }); + + const conicColorBg = `conic-gradient(from ${fromDeg}, ${conicColors.join(', ')})`; + + return ( + <> + {circleNode} + + +
+ + + ); +}); + +if (process.env.NODE_ENV !== 'production') { + PtgCircle.displayName = 'PtgCircle'; +} + +export default PtgCircle; diff --git a/src/Circle.tsx b/src/Circle/index.tsx similarity index 58% rename from src/Circle.tsx rename to src/Circle/index.tsx index 48a300c..20714f5 100644 --- a/src/Circle.tsx +++ b/src/Circle/index.tsx @@ -1,67 +1,17 @@ import * as React from 'react'; import classNames from 'classnames'; -import { defaultProps, useTransitionDuration } from './common'; -import type { ProgressProps } from './interface'; -import useId from './hooks/useId'; - -function stripPercentToNumber(percent: string) { - return +percent.replace('%', ''); -} +import { defaultProps, useTransitionDuration } from '../common'; +import type { ProgressProps } from '../interface'; +import useId from '../hooks/useId'; +import ColorGradient from './ColorGradient'; +import PtgCircle from './PtgCircle'; +import { VIEW_BOX_SIZE, getCircleStyle, isConicColor } from './util'; function toArray(value: T | T[]): T[] { const mergedValue = value ?? []; return Array.isArray(mergedValue) ? mergedValue : [mergedValue]; } -const VIEW_BOX_SIZE = 100; - -const getCircleStyle = ( - perimeter: number, - perimeterWithoutGap: number, - offset: number, - percent: number, - rotateDeg: number, - gapDegree, - gapPosition: ProgressProps['gapPosition'] | undefined, - strokeColor: string | Record, - strokeLinecap: ProgressProps['strokeLinecap'], - strokeWidth, - stepSpace = 0, -) => { - const offsetDeg = (offset / 100) * 360 * ((360 - gapDegree) / 360); - const positionDeg = - gapDegree === 0 - ? 0 - : { - bottom: 0, - top: 180, - left: 90, - right: -90, - }[gapPosition]; - - let strokeDashoffset = ((100 - percent) / 100) * perimeterWithoutGap; - // Fix percent accuracy when strokeLinecap is round - // https://github.com/ant-design/ant-design/issues/35009 - if (strokeLinecap === 'round' && percent !== 100) { - strokeDashoffset += strokeWidth / 2; - // when percent is small enough (<= 1%), keep smallest value to avoid it's disappearance - if (strokeDashoffset >= perimeterWithoutGap) { - strokeDashoffset = perimeterWithoutGap - 0.01; - } - } - - return { - stroke: typeof strokeColor === 'string' ? strokeColor : undefined, - strokeDasharray: `${perimeterWithoutGap}px ${perimeter}`, - strokeDashoffset: strokeDashoffset + stepSpace, - transform: `rotate(${rotateDeg + offsetDeg + positionDeg}deg)`, - transformOrigin: '0 0', - transition: - 'stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s', - fillOpacity: 0, - }; -}; - const Circle: React.FC = (props) => { const { id, @@ -83,15 +33,26 @@ const Circle: React.FC = (props) => { ...props, }; + const halfSize = VIEW_BOX_SIZE / 2; + const mergedId = useId(id); const gradientId = `${mergedId}-gradient`; - const radius = VIEW_BOX_SIZE / 2 - strokeWidth / 2; + const radius = halfSize - strokeWidth / 2; const perimeter = Math.PI * 2 * radius; const rotateDeg = gapDegree > 0 ? 90 + gapDegree / 2 : -90; const perimeterWithoutGap = perimeter * ((360 - gapDegree) / 360); const { count: stepCount, space: stepSpace } = typeof steps === 'object' ? steps : { count: steps, space: 2 }; + const percentList = toArray(percent); + const strokeColorList = toArray(strokeColor); + const gradient = strokeColorList.find((color) => color && typeof color === 'object') as Record< + string, + string + >; + const isConicGradient = isConicColor(gradient); + const mergedStrokeLinecap = isConicGradient ? 'butt' : strokeLinecap; + const circleStyle = getCircleStyle( perimeter, perimeterWithoutGap, @@ -101,12 +62,9 @@ const Circle: React.FC = (props) => { gapDegree, gapPosition, trailColor, - strokeLinecap, + mergedStrokeLinecap, strokeWidth, ); - const percentList = toArray(percent); - const strokeColorList = toArray(strokeColor); - const gradient = strokeColorList.find((color) => color && typeof color === 'object'); const paths = useTransitionDuration(); @@ -115,7 +73,6 @@ const Circle: React.FC = (props) => { return percentList .map((ptg, index) => { const color = strokeColorList[index] || strokeColorList[strokeColorList.length - 1]; - const stroke = color && typeof color === 'object' ? `url(#${gradientId})` : undefined; const circleStyleForStack = getCircleStyle( perimeter, perimeterWithoutGap, @@ -125,22 +82,24 @@ const Circle: React.FC = (props) => { gapDegree, gapPosition, color, - strokeLinecap, + mergedStrokeLinecap, strokeWidth, ); stackPtg += ptg; + return ( - { // https://reactjs.org/docs/refs-and-the-dom.html#callback-refs // React will call the ref callback with the DOM element when the component mounts, @@ -149,6 +108,7 @@ const Circle: React.FC = (props) => { paths[index] = elem; }} + size={VIEW_BOX_SIZE} /> ); }) @@ -186,10 +146,9 @@ const Circle: React.FC = (props) => { key={index} className={`${prefixCls}-circle-path`} r={radius} - cx={0} - cy={0} + cx={halfSize} + cy={halfSize} stroke={stroke} - // strokeLinecap={strokeLinecap} strokeWidth={strokeWidth} opacity={1} style={circleStyleForStack} @@ -204,31 +163,24 @@ const Circle: React.FC = (props) => { return ( - {gradient && ( - - - {Object.keys(gradient) - .sort((a, b) => stripPercentToNumber(a) - stripPercentToNumber(b)) - .map((key, index) => ( - - ))} - - + {/* Line Gradient */} + {gradient && !isConicGradient && ( + )} {!stepCount && ( diff --git a/src/Circle/util.ts b/src/Circle/util.ts new file mode 100644 index 0000000..6062645 --- /dev/null +++ b/src/Circle/util.ts @@ -0,0 +1,57 @@ +import type { StrokeColorObject, StrokeColorType } from '../interface'; +import type { ProgressProps } from '..'; + +export const VIEW_BOX_SIZE = 100; + +export function isConicColor(gradient: StrokeColorObject) { + return gradient && gradient.conic; +} + +export const getCircleStyle = ( + perimeter: number, + perimeterWithoutGap: number, + offset: number, + percent: number, + rotateDeg: number, + gapDegree, + gapPosition: ProgressProps['gapPosition'] | undefined, + strokeColor: StrokeColorType, + strokeLinecap: ProgressProps['strokeLinecap'], + strokeWidth, + stepSpace = 0, +) => { + const offsetDeg = (offset / 100) * 360 * ((360 - gapDegree) / 360); + const positionDeg = + gapDegree === 0 + ? 0 + : { + bottom: 0, + top: 180, + left: 90, + right: -90, + }[gapPosition]; + + let strokeDashoffset = ((100 - percent) / 100) * perimeterWithoutGap; + // Fix percent accuracy when strokeLinecap is round + // https://github.com/ant-design/ant-design/issues/35009 + if (strokeLinecap === 'round' && percent !== 100) { + strokeDashoffset += strokeWidth / 2; + // when percent is small enough (<= 1%), keep smallest value to avoid it's disappearance + if (strokeDashoffset >= perimeterWithoutGap) { + strokeDashoffset = perimeterWithoutGap - 0.01; + } + } + + const halfSize = VIEW_BOX_SIZE / 2; + + return { + stroke: typeof strokeColor === 'string' ? strokeColor : undefined, + strokeDasharray: `${perimeterWithoutGap}px ${perimeter}`, + strokeDashoffset: strokeDashoffset + stepSpace, + transform: `rotate(${rotateDeg + offsetDeg + positionDeg}deg)`, + transformOrigin: `${halfSize}px ${halfSize}px`, + transition: + 'stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s', + fillOpacity: 0, + }; +}; diff --git a/src/interface.ts b/src/interface.ts index 3a46bd7..529e095 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -16,7 +16,11 @@ export interface ProgressProps { steps?: number | { count: number; space: number }; } -export type BaseStrokeColorType = string | Record; +export type StrokeColorObject = Partial> & { + conic?: boolean; +}; + +export type BaseStrokeColorType = string | StrokeColorObject; export type StrokeColorType = BaseStrokeColorType | BaseStrokeColorType[]; diff --git a/tests/__snapshots__/conic.spec.tsx.snap b/tests/__snapshots__/conic.spec.tsx.snap new file mode 100644 index 0000000..70d7abc --- /dev/null +++ b/tests/__snapshots__/conic.spec.tsx.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Circle.conic gapDegree 1`] = ` + + + + + + + +
+ + + +`; + +exports[`Circle.conic should work 1`] = ` + + + + + + + +
+ + + +`; diff --git a/tests/__snapshots__/index.spec.js.snap b/tests/__snapshots__/index.spec.js.snap index c7bc0b0..93fcf70 100644 --- a/tests/__snapshots__/index.spec.js.snap +++ b/tests/__snapshots__/index.spec.js.snap @@ -87,7 +87,7 @@ exports[`Progress Circle should gradient works and circles have different gradie
@@ -182,157 +182,157 @@ exports[`Progress Circle should show right gapPosition 1`] = ` diff --git a/tests/conic.spec.tsx b/tests/conic.spec.tsx new file mode 100644 index 0000000..be20734 --- /dev/null +++ b/tests/conic.spec.tsx @@ -0,0 +1,42 @@ +/* eslint-disable react/no-render-return-value */ +// eslint-disable-next-line max-classes-per-file +import React from 'react'; +import { render } from '@testing-library/react'; +import { Circle } from '../src'; + +describe('Circle.conic', () => { + it('should work', () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it('gapDegree', () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); +});