From 7e740aece109fc26a61d800b39e0f8f6c7544179 Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Mon, 26 May 2025 18:49:16 +0300 Subject: [PATCH] feat: unstable_ExpressiveSpinner --- .../ExpressiveSpinner/Spinner.module.css | 13 + .../ExpressiveSpinner/Spinner.stories.tsx | 18 + .../Spinner/ExpressiveSpinner/Spinner.tsx | 68 ++++ .../Spinner/ExpressiveSpinner/icons.tsx | 104 ++++++ .../vkui/src/components/Spinner/Readme.md | 21 ++ .../vkui/src/components/Spinner/SvgIcon.tsx | 31 ++ .../vkui/src/components/Spinner/icons.tsx | 21 +- packages/vkui/src/hooks/useAnimationFrame.tsx | 43 +++ packages/vkui/src/index.ts | 1 + packages/vkui/src/lib/array.test.ts | 9 + packages/vkui/src/lib/array.ts | 19 + packages/vkui/src/lib/curve.test.ts | 11 + packages/vkui/src/lib/curve.ts | 36 ++ packages/vkui/src/lib/fx.test.ts | 28 ++ packages/vkui/src/lib/fx.ts | 9 +- .../src/lib/material/shapes/Shape.test.tsx | 7 + .../vkui/src/lib/material/shapes/Shape.tsx | 17 + ...l-shapes-android-chromium-light-1-snap.png | 3 + .../material/shapes/shapes.e2e-playground.tsx | 33 ++ .../src/lib/material/shapes/shapes.e2e.tsx | 17 + .../lib/material/shapes/shapes.stories.tsx | 145 ++++++++ .../src/lib/material/shapes/shapes.test.ts | 8 + .../vkui/src/lib/material/shapes/shapes.ts | 329 ++++++++++++++++++ packages/vkui/src/lib/math.test.ts | 9 + packages/vkui/src/lib/math.ts | 37 ++ .../vkui/src/lib/svg/path/approximate.test.ts | 30 ++ packages/vkui/src/lib/svg/path/approximate.ts | 81 +++++ .../vkui/src/lib/svg/path/interpolate.test.ts | 68 ++++ packages/vkui/src/lib/svg/path/interpolate.ts | 151 ++++++++ packages/vkui/src/lib/svg/path/path.test.ts | 18 + packages/vkui/src/lib/svg/path/path.ts | 102 ++++++ packages/vkui/src/lib/svg/path/point.ts | 2 + .../vkui/src/lib/svg/path/transform.test.ts | 55 +++ packages/vkui/src/lib/svg/path/transform.ts | 147 ++++++++ .../src/lib/touch/UIPanGestureRecognizer.ts | 4 +- packages/vkui/src/types.ts | 16 + 36 files changed, 1691 insertions(+), 20 deletions(-) create mode 100644 packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.module.css create mode 100644 packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.stories.tsx create mode 100644 packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx create mode 100644 packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx create mode 100644 packages/vkui/src/components/Spinner/SvgIcon.tsx create mode 100644 packages/vkui/src/hooks/useAnimationFrame.tsx create mode 100644 packages/vkui/src/lib/array.test.ts create mode 100644 packages/vkui/src/lib/array.ts create mode 100644 packages/vkui/src/lib/curve.test.ts create mode 100644 packages/vkui/src/lib/curve.ts create mode 100644 packages/vkui/src/lib/fx.test.ts create mode 100644 packages/vkui/src/lib/material/shapes/Shape.test.tsx create mode 100644 packages/vkui/src/lib/material/shapes/Shape.tsx create mode 100644 packages/vkui/src/lib/material/shapes/__image_snapshots__/material-shapes-android-chromium-light-1-snap.png create mode 100644 packages/vkui/src/lib/material/shapes/shapes.e2e-playground.tsx create mode 100644 packages/vkui/src/lib/material/shapes/shapes.e2e.tsx create mode 100644 packages/vkui/src/lib/material/shapes/shapes.stories.tsx create mode 100644 packages/vkui/src/lib/material/shapes/shapes.test.ts create mode 100644 packages/vkui/src/lib/material/shapes/shapes.ts create mode 100644 packages/vkui/src/lib/math.test.ts create mode 100644 packages/vkui/src/lib/math.ts create mode 100644 packages/vkui/src/lib/svg/path/approximate.test.ts create mode 100644 packages/vkui/src/lib/svg/path/approximate.ts create mode 100644 packages/vkui/src/lib/svg/path/interpolate.test.ts create mode 100644 packages/vkui/src/lib/svg/path/interpolate.ts create mode 100644 packages/vkui/src/lib/svg/path/path.test.ts create mode 100644 packages/vkui/src/lib/svg/path/path.ts create mode 100644 packages/vkui/src/lib/svg/path/point.ts create mode 100644 packages/vkui/src/lib/svg/path/transform.test.ts create mode 100644 packages/vkui/src/lib/svg/path/transform.ts diff --git a/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.module.css b/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.module.css new file mode 100644 index 00000000000..39652d930d9 --- /dev/null +++ b/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.module.css @@ -0,0 +1,13 @@ +.host { + display: flex; + align-items: center; + justify-content: center; + inline-size: 100%; + block-size: 100%; + color: var(--vkui--color_icon_medium); + will-change: contents; +} + +.noColor { + color: currentColor; +} diff --git a/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.stories.tsx b/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.stories.tsx new file mode 100644 index 00000000000..377eaedc551 --- /dev/null +++ b/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.stories.tsx @@ -0,0 +1,18 @@ +import { withCartesian } from '@project-tools/storybook-addon-cartesian'; +import type { Meta, StoryObj } from '@storybook/react'; +import { CanvasFullLayout } from '../../../storybook/constants'; +import { createStoryParameters } from '../../../testing/storybook/createStoryParameters'; +import { type MaterialSpinnerProps, Spinner } from './Spinner'; + +const story: Meta = { + title: 'Blocks/Spinner/Expressive', + component: Spinner, + parameters: createStoryParameters('Spinner', CanvasFullLayout), + decorators: [withCartesian], +}; + +export default story; + +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx b/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx new file mode 100644 index 00000000000..47ff17f2962 --- /dev/null +++ b/packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { classNames, hasReactNode } from '@vkontakte/vkjs'; +import { usePlatform } from '../../../hooks/usePlatform'; +import * as shapes from '../../../lib/material/shapes/shapes'; +import { RootComponent } from '../../RootComponent/RootComponent'; +import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; +import { Spinner as SimpleSpinner, type SpinnerProps } from '../Spinner'; +import { IconMaterial } from './icons'; +import styles from './Spinner.module.css'; + +const iconSizeMap = { + s: 16, + m: 24, + l: 32, + xl: 44, +} as const; + +const defaultShapesList = [ + shapes.softBurstParams, + shapes.cookie9Params, + shapes.pentagonParams, + shapes.pillParams, + shapes.sunnyParams, + shapes.cookie4Params, + shapes.ovalParams, +] as const; + +export interface MaterialSpinnerProps extends SpinnerProps { + /** + * Последовательность форм между которыми будет происходить анимация. + */ + polygons?: readonly [shapes.ShapeParameters, shapes.ShapeParameters, ...shapes.ShapeParameters[]]; +} + +function MaterialSpinner({ + polygons = defaultShapesList, + size = 'm', + children = 'Загружается...', + disableAnimation = false, + noColor = false, + ...restProps +}: MaterialSpinnerProps) { + const iconSize = iconSizeMap[size]; + + return ( + + + {hasReactNode(children) && {children}} + + ); +} + +export function Spinner(props: SpinnerProps) { + const platform = usePlatform(); + + const Component = platform === 'ios' ? SimpleSpinner : MaterialSpinner; + + return ; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const unstable_ExpressiveSpinner = Spinner; diff --git a/packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx b/packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx new file mode 100644 index 00000000000..04db1b59070 --- /dev/null +++ b/packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx @@ -0,0 +1,104 @@ +'use client'; + +import * as React from 'react'; +import { useAnimationFrame } from '../../../hooks/useAnimationFrame'; +import { useReducedMotion } from '../../../lib/animation'; +import * as shapes from '../../../lib/material/shapes/shapes'; +import { interpolate } from '../../../lib/svg/path/interpolate'; +import { svgPathToString } from '../../../lib/svg/path/path'; +import * as operation from '../../../lib/svg/path/transform'; +import { SvgIcon } from '../SvgIcon'; + +interface IconMaterialProps { + /** + * Список форм. + */ + polygons: readonly shapes.ShapeParameters[]; + /** + * Размер иконки. + */ + size: number; + /** + * Отключение анимации. + */ + disableAnimation: boolean; +} + +export function IconMaterial(props: IconMaterialProps) { + return ( + + + + ); +} + +const globalRotationDuration = 4666; +const morphDuration = 200; +const morphInterval = 650; +const fullRotation = 360; +const quarterRotation = fullRotation / 4; + +function calcProgress(startTime: number, time: number, duration: number, delay = 0) { + const fullDuration = duration + delay; + + const timeProgress = fullDuration * (((time - startTime) % fullDuration) / fullDuration); + + if (timeProgress < delay) { + return 0; + } + + return (timeProgress - delay) / duration; +} + +function IconMaterialPath({ size, polygons, disableAnimation }: IconMaterialProps) { + const ref = React.useRef(null); + + const morphSequence = React.useMemo(() => { + function getShape(index: number, size: number) { + return shapes.shapeWithRotate(polygons[index], size); + } + + return new Array(polygons.length).fill(0).map((_, index) => { + return interpolate(getShape(index, size), getShape((index + 1) % polygons.length, size), { + maxSegmentLength: 2, + }); + }); + }, [size, polygons]); + + const initialPath = React.useMemo(() => svgPathToString(morphSequence[0](0)), [morphSequence]); + + const callback = React.useCallback( + (time: number) => { + const rotationAnimationProgress = calcProgress(0, time, globalRotationDuration); + const globalRotation = rotationAnimationProgress * fullRotation; + + // TODO: spring({ + // dampingRatio: 0.6, + // stiffness: 200, + // visibilityThreshold: 0.1, + // }) + const morphProgress = calcProgress(0, time, morphDuration, morphInterval); + + const roundMorphIndex = Math.floor(time / (morphDuration + morphInterval)); + + const currentMorphIndex = roundMorphIndex % morphSequence.length; + + const morphRotationTargetAngle = (roundMorphIndex * quarterRotation) % fullRotation; + const rotation = morphProgress * quarterRotation + morphRotationTargetAngle + globalRotation; + + const morphFn = morphSequence[currentMorphIndex]; + const morph = morphFn(morphProgress); + + ref.current!.setAttribute( + 'd', + svgPathToString(operation.rotate(morph, size / 2, size / 2, rotation)), + ); + }, + [morphSequence, size], + ); + + const isReducedMotion = useReducedMotion(); + useAnimationFrame(callback, disableAnimation && isReducedMotion); + + return ; +} diff --git a/packages/vkui/src/components/Spinner/Readme.md b/packages/vkui/src/components/Spinner/Readme.md index dfed5173807..1ccdf08cfff 100644 --- a/packages/vkui/src/components/Spinner/Readme.md +++ b/packages/vkui/src/components/Spinner/Readme.md @@ -14,3 +14,24 @@ Кастомный текст вместо "Загружается...", который озвучит скринридер ``` + +
+ +## unstable_ExpressiveSpinner + +Нестабильный компонент индикации загрузки в стиле +[M3 Expressive](https://m3.material.io/components/loading-indicator/overview). +Принимает все свойства, которые принимает компонент `Spinner`. +Для платформы `ios` используется обычный `Spinner`. + +```jsx { "props": { "layout": false, "iframe": false } } + + + +``` diff --git a/packages/vkui/src/components/Spinner/SvgIcon.tsx b/packages/vkui/src/components/Spinner/SvgIcon.tsx new file mode 100644 index 00000000000..d0e8028de9e --- /dev/null +++ b/packages/vkui/src/components/Spinner/SvgIcon.tsx @@ -0,0 +1,31 @@ +import { type HasRootRef } from '../../types'; +import { RootComponent } from '../RootComponent/RootComponent'; + +/** + * Возвращает класс для иконки. + */ +export function iconClassName(size: number) { + return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`; +} + +interface SvgIconProps extends React.ComponentProps<'svg'>, HasRootRef { + /** + * Размер иконки. + */ + size: number; +} + +export function SvgIcon({ size, children, ...restProps }: SvgIconProps) { + return ( + + ); +} diff --git a/packages/vkui/src/components/Spinner/icons.tsx b/packages/vkui/src/components/Spinner/icons.tsx index 7906e6d3c98..ddbbbd0d921 100644 --- a/packages/vkui/src/components/Spinner/icons.tsx +++ b/packages/vkui/src/components/Spinner/icons.tsx @@ -1,57 +1,54 @@ import * as React from 'react'; - -function iconClassName(size: number) { - return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`; -} +import { SvgIcon } from './SvgIcon'; export function Icon16Spinner({ children }: React.PropsWithChildren) { return ( - + ); } export function Icon24Spinner({ children }: React.PropsWithChildren) { return ( - + ); } export function Icon32Spinner({ children }: React.PropsWithChildren) { return ( - + ); } export function Icon44Spinner({ children }: React.PropsWithChildren) { return ( - + ); } diff --git a/packages/vkui/src/hooks/useAnimationFrame.tsx b/packages/vkui/src/hooks/useAnimationFrame.tsx new file mode 100644 index 00000000000..818552be8be --- /dev/null +++ b/packages/vkui/src/hooks/useAnimationFrame.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +/** + * Обертка над `requestAnimationFrame`. В функцию `` пере + * + * ```ts + * const animate = React.useCallback((delta: number) => { + * console.log('Delta:', delta); + * }, []); + * + * useAnimationFrame(animate); + * ``` + * + * @param callback Функция, которая будет вызываться каждый раз при обновлении анимации. + * Принимает параметр `delta` - время в миллисекундах, прошедшее с первого кадра анимации. + */ +export function useAnimationFrame(callback: (delta: number) => void, disableAnimation = false) { + const handleRef = React.useRef(undefined); + const startTimestampRef = React.useRef(undefined); + + const frameRequestCallback = React.useCallback( + (timestamp: number) => { + if (disableAnimation) { + return; + } + + if (startTimestampRef.current === undefined) { + startTimestampRef.current = timestamp; + } + + const delta = timestamp - startTimestampRef.current; + callback(delta); + + handleRef.current = requestAnimationFrame(frameRequestCallback); + }, + [callback, disableAnimation], + ); + + React.useEffect(() => { + handleRef.current = requestAnimationFrame(frameRequestCallback); + return () => cancelAnimationFrame(handleRef.current!); + }, [frameRequestCallback]); +} diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts index 3cd8f4a1c64..7f41a90d491 100644 --- a/packages/vkui/src/index.ts +++ b/packages/vkui/src/index.ts @@ -233,6 +233,7 @@ export { TabsItem } from './components/TabsItem/TabsItem'; export type { TabsItemProps } from './components/TabsItem/TabsItem'; export { Spinner } from './components/Spinner/Spinner'; export type { SpinnerProps } from './components/Spinner/Spinner'; +export { unstable_ExpressiveSpinner } from './components/Spinner/ExpressiveSpinner/Spinner'; export { PullToRefresh } from './components/PullToRefresh/PullToRefresh'; export type { PullToRefreshProps } from './components/PullToRefresh/PullToRefresh'; export { Link } from './components/Link/Link'; diff --git a/packages/vkui/src/lib/array.test.ts b/packages/vkui/src/lib/array.test.ts new file mode 100644 index 00000000000..9837bf117fb --- /dev/null +++ b/packages/vkui/src/lib/array.test.ts @@ -0,0 +1,9 @@ +import * as array from './array'; + +it('array.copy', () => { + const originalArray = [1, 2, 3]; + const copiedArray = array.copy(originalArray); + + expect(copiedArray).toStrictEqual(originalArray); + expect(copiedArray).not.toBe(originalArray); +}); diff --git a/packages/vkui/src/lib/array.ts b/packages/vkui/src/lib/array.ts new file mode 100644 index 00000000000..abfbd822842 --- /dev/null +++ b/packages/vkui/src/lib/array.ts @@ -0,0 +1,19 @@ +import { type Writeable } from '@vkontakte/vkjs'; + +/** + * Поверхностно копирует массив + * + * ```ts + * import * as array from './array'; + * import * as assert from 'node:assert/strict'; + * + * const originalArray = [1, 2, 3]; + * const copiedArray = array.copy(originalArray); + * + * assert.deepEqual(copiedArray, originalArray); + * assert.notEqual(copiedArray, originalArray); + * ``` + */ +export function copy>(array: T): Writeable { + return array.slice(0) as unknown as T; +} diff --git a/packages/vkui/src/lib/curve.test.ts b/packages/vkui/src/lib/curve.test.ts new file mode 100644 index 00000000000..548e3725de7 --- /dev/null +++ b/packages/vkui/src/lib/curve.test.ts @@ -0,0 +1,11 @@ +import { cubicBezierOneDimensional, cubicBezierTwoDimensional } from './curve'; + +it('cubicBezierOneDimensional', () => { + expect(cubicBezierOneDimensional(0, 1, 2, 3, 0.5)).toBeCloseTo(1.5); +}); + +it('cubicBezierTwoDimensional', () => { + const [x, y] = cubicBezierTwoDimensional(0, 0, 1, 1, 2, 1, 3, 0, 0.5); + expect(x).toBeCloseTo(1.5); + expect(y).toBeCloseTo(0.75); +}); diff --git a/packages/vkui/src/lib/curve.ts b/packages/vkui/src/lib/curve.ts new file mode 100644 index 00000000000..deddb5efc0f --- /dev/null +++ b/packages/vkui/src/lib/curve.ts @@ -0,0 +1,36 @@ +/** + * Кубическая кривая Безье для одномерного пространства + */ +export function cubicBezierOneDimensional( + p0: number, + p1: number, + p2: number, + p3: number, + t: number, +) { + const cx = 3 * (p1 - p0); + const bx = 3 * (p2 - p1) - cx; + const ax = p3 - p0 - cx - bx; + const x = ax * Math.pow(t, 3) + bx * Math.pow(t, 2) + cx * t + p0; + return x; +} + +/** + * Кубическая кривая Безье для двумерного пространства + */ +export function cubicBezierTwoDimensional( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + t: number, +): [number, number] { + const x = cubicBezierOneDimensional(x0, x1, x2, x3, t); + const y = cubicBezierOneDimensional(y0, y1, y2, y3, t); + + return [x, y]; +} diff --git a/packages/vkui/src/lib/fx.test.ts b/packages/vkui/src/lib/fx.test.ts new file mode 100644 index 00000000000..e8441b4354d --- /dev/null +++ b/packages/vkui/src/lib/fx.test.ts @@ -0,0 +1,28 @@ +import { cubicBezier, easeInOutSine } from './fx'; + +describe('easeInOutSine', () => { + it('should return 0 when x is 0', () => { + expect(easeInOutSine(0)).toBe(0); + }); + + it('should return 0.5 when x is 0.5', () => { + expect(easeInOutSine(0.5)).toBeCloseTo(0.5); + }); + + it('should return 1 when x is 1', () => { + expect(easeInOutSine(1)).toBe(1); + }); + + it('should be within bounds 0 and 1 for x in bounds 0 and 1', () => { + for (let x = 0; x <= 1; x += 0.1) { + expect(easeInOutSine(x)).toBeGreaterThanOrEqual(0); + expect(easeInOutSine(x)).toBeLessThanOrEqual(1); + } + }); +}); + +it('cubicBezier', () => { + const bezierFunction = cubicBezier(0.5, 0.5); + + expect(bezierFunction(0.25)).toBeCloseTo(0.3); +}); diff --git a/packages/vkui/src/lib/fx.ts b/packages/vkui/src/lib/fx.ts index a1d10e610e5..7e62e6e0c02 100644 --- a/packages/vkui/src/lib/fx.ts +++ b/packages/vkui/src/lib/fx.ts @@ -1,3 +1,5 @@ +import { cubicBezierOneDimensional } from './curve'; + /** * ease function * @param x absolute progress of the animation in bounds 0 (beginning) and 1 (end) @@ -8,11 +10,6 @@ export function easeInOutSine(x: number): number { export function cubicBezier(x1: number, x2: number) { return function (progress: number): number { - const t = progress; - const cx = 3 * x1; - const bx = 3 * (x2 - x1) - cx; - const ax = 1 - cx - bx; - const x = ax * Math.pow(t, 3) + bx * Math.pow(t, 2) + cx * t; - return x; + return cubicBezierOneDimensional(0, x1, x2, 1, progress); }; } diff --git a/packages/vkui/src/lib/material/shapes/Shape.test.tsx b/packages/vkui/src/lib/material/shapes/Shape.test.tsx new file mode 100644 index 00000000000..ea0c601672e --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/Shape.test.tsx @@ -0,0 +1,7 @@ +import { baselineComponent } from '../../../testing/utils'; +import { Shape } from './Shape'; +import * as shapes from './shapes'; + +describe('Shape', () => { + baselineComponent((props) => ); +}); diff --git a/packages/vkui/src/lib/material/shapes/Shape.tsx b/packages/vkui/src/lib/material/shapes/Shape.tsx new file mode 100644 index 00000000000..f289610f8ec --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/Shape.tsx @@ -0,0 +1,17 @@ +import { RootComponent } from '../../../components/RootComponent/RootComponent'; +import { type HasRootRef } from '../../../types'; +import { svgPathToString } from '../../svg/path/path'; +import * as shapes from './shapes'; + +export interface ShapeProps extends React.ComponentProps<'svg'>, HasRootRef { + size?: number; + params: shapes.ShapeParameters; +} + +export function Shape({ size = 24, params, ...props }: ShapeProps) { + return ( + + + + ); +} diff --git a/packages/vkui/src/lib/material/shapes/__image_snapshots__/material-shapes-android-chromium-light-1-snap.png b/packages/vkui/src/lib/material/shapes/__image_snapshots__/material-shapes-android-chromium-light-1-snap.png new file mode 100644 index 00000000000..4e4aa2c91e3 --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/__image_snapshots__/material-shapes-android-chromium-light-1-snap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e3bbfa5a3669cbcbefa1a2954c60d54b5a2d4cc01475ccf38a87f27fdf58950 +size 16357 diff --git a/packages/vkui/src/lib/material/shapes/shapes.e2e-playground.tsx b/packages/vkui/src/lib/material/shapes/shapes.e2e-playground.tsx new file mode 100644 index 00000000000..e38f7f869bf --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/shapes.e2e-playground.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { chunkArray } from '@vkontakte/vkjs'; +import { type ComponentPlaygroundProps } from '@vkui-e2e/playground-helpers'; +import { Shape } from './Shape'; +import * as shapes from './shapes'; + +export const ShapePlayground = ({}: ComponentPlaygroundProps) => { + // TODO [@vkontakte/vkjs@>=2.1.0]: удалить as https://github.com/VKCOM/vkjs/pull/695/files + const chunkShapes = chunkArray(shapes.shapeList as unknown as shapes.ShapeParameters[], 5); + + return ( + + + {chunkShapes.map((row, index) => ( + + + {row.map((shapeParams, i) => ( + + ))} + + + {row.map((shapeParams, i) => ( + + ))} + + + ))} + +
+ +
{shapes.shapeNameMap.get(shapeParams)}
+ ); +}; diff --git a/packages/vkui/src/lib/material/shapes/shapes.e2e.tsx b/packages/vkui/src/lib/material/shapes/shapes.e2e.tsx new file mode 100644 index 00000000000..7d4491508da --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/shapes.e2e.tsx @@ -0,0 +1,17 @@ +import { test } from '@vkui-e2e/test'; +import { ShapePlayground } from './shapes.e2e-playground'; + +test.use({ + onlyForPlatforms: ['android'], + onlyForBrowsers: ['chromium'], + onlyForColorSchemes: ['light'], +}); + +test('material-shapes', async ({ + mount, + expectScreenshotClippedToContent, + componentPlaygroundProps, +}) => { + await mount(); + await expectScreenshotClippedToContent(); +}); diff --git a/packages/vkui/src/lib/material/shapes/shapes.stories.tsx b/packages/vkui/src/lib/material/shapes/shapes.stories.tsx new file mode 100644 index 00000000000..d93fc661821 --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/shapes.stories.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { type SelectProps } from '../../../components/CustomSelect/CustomSelect'; +import { CustomSelectOption } from '../../../components/CustomSelectOption/CustomSelectOption'; +import { Flex } from '../../../components/Flex/Flex'; +import { FormItem } from '../../../components/FormItem/FormItem'; +import { Group } from '../../../components/Group/Group'; +import { Input } from '../../../components/Input/Input'; +import { Select } from '../../../components/Select/Select'; +import { Slider } from '../../../components/Slider/Slider'; +import { CanvasFullLayout, DisableCartesianParam } from '../../../storybook/constants'; +import { interpolate } from '../../svg/path/interpolate'; +import { svgPathToString } from '../../svg/path/path'; +import * as transform from '../../svg/path/transform'; +import { Shape } from './Shape'; +import * as shapes from './shapes'; + +const story: Meta = { + title: 'DevTools/lib/material/shapes', + tags: ['test'], // скрываем из публичной документации + component: () =>
, + parameters: { ...CanvasFullLayout, ...DisableCartesianParam }, +}; + +// eslint-disable-next-line import/no-default-export +export default story; + +function SelectShape(props: { onChange: SelectProps['onChange']; value: string }) { + return ( + setSize(Number(e.target.value) || 38)} + step={1} + min={0} + /> + + + setMaxSegmentLength(Number(e.target.value) || 1)} + min={0.01} + max={10} + /> + + + setAngle(value)} + /> + +
+ + + + +
+ + setProgress(value)} + /> + +
+
+ + + ); +} + +export const Morph: StoryObj = { + render: MorphPlayground, +}; diff --git a/packages/vkui/src/lib/material/shapes/shapes.test.ts b/packages/vkui/src/lib/material/shapes/shapes.test.ts new file mode 100644 index 00000000000..347cf3d6a80 --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/shapes.test.ts @@ -0,0 +1,8 @@ +import { svgPathToString } from '../../svg/path/path'; +import * as shapes from './shapes'; + +it('shapes.shapeWithRotate', () => { + expect(svgPathToString(shapes.shapeWithRotate(shapes.ovalParams, 38))).toBe( + 'M27.1300,27.1300C20.1700,34.0900,10.8880,36.0940,6.3970,31.6030C1.9060,27.1120,3.9100,17.8300,10.8700,10.8700C17.8300,3.9100,27.1120,1.9060,31.6030,6.3970C36.0940,10.8880,34.0900,20.1700,27.1300,27.1300Z', + ); +}); diff --git a/packages/vkui/src/lib/material/shapes/shapes.ts b/packages/vkui/src/lib/material/shapes/shapes.ts new file mode 100644 index 00000000000..4f2355e293a --- /dev/null +++ b/packages/vkui/src/lib/material/shapes/shapes.ts @@ -0,0 +1,329 @@ +/** + * Набор форм M3 + * + * https://m3.material.io/styles/shape/overview-principles + */ +import { type DeepReadonly, type ReadonlyWeakMap } from '../../../types'; +import { type SVGPathSupport } from '../../svg/path/path'; +import * as transform from '../../svg/path/transform'; + +export interface ShapeParameters { + /** + * SVG путь для фигуры размером [0,1] на [0,1] + */ + readonly path: DeepReadonly; + /** + * Масштабирующий коэффициент предназначен для возможности помещения + * в произвольный контейнер фигуры учитывая вращение + */ + readonly rotateScaleFactor: number; +} + +/** + * Возвращает фигуру определенного размера + */ +export function shape(shapeParams: ShapeParameters, size: number): SVGPathSupport { + return transform.scale(shapeParams.path, size, size); +} + +/** + * Возвращает фигуру с возможностью поворота, которая помещается в контейнер + * определенного размера + */ +export function shapeWithRotate(shapeParams: ShapeParameters, size: number): SVGPathSupport { + const scaleSize = shapeParams.rotateScaleFactor * size; + const translateFactor = (size - scaleSize) / 2; + + return transform.translate(shape(shapeParams, scaleSize), translateFactor, translateFactor); +} + +export const ovalParams: ShapeParameters = { + path: [ + ['M', [0.771, 0.771]], + ['C', [0.539, 1.003, 0.2296, 1.0698, 0.0799, 0.9201]], + ['C', [-0.0698, 0.7704, -0.003, 0.461, 0.229, 0.229]], + ['C', [0.461, -0.003, 0.7704, -0.0698, 0.9201, 0.0799]], + ['C', [1.0698, 0.2296, 1.003, 0.539, 0.771, 0.771]], + ['Z', []], + ], + rotateScaleFactor: 30 / 38, +}; + +export const cookie4Params: ShapeParameters = { + path: [ + ['M', [0.6383, 0.0222]], + ['C', [0.8531, -0.0711, 1.0711, 0.1469, 0.9778, 0.3617]], + ['L', [0.9624, 0.3972]], + ['C', [0.9339, 0.4628, 0.9339, 0.5372, 0.9624, 0.6028]], + ['L', [0.9778, 0.6383]], + ['C', [1.0711, 0.8531, 0.8531, 1.0711, 0.6383, 0.9778]], + ['L', [0.6028, 0.9624]], + ['C', [0.5372, 0.9339, 0.4628, 0.9339, 0.3972, 0.9624]], + ['L', [0.3617, 0.9778]], + ['C', [0.1469, 1.0711, -0.0711, 0.8531, 0.0222, 0.6383]], + ['L', [0.0376, 0.6028]], + ['C', [0.0661, 0.5372, 0.0661, 0.4628, 0.0376, 0.3972]], + ['L', [0.0222, 0.3617]], + ['C', [-0.0711, 0.1469, 0.1469, -0.0711, 0.3617, 0.0222]], + ['L', [0.3972, 0.0376]], + ['C', [0.4628, 0.0661, 0.5372, 0.0661, 0.6028, 0.0376]], + ['L', [0.6383, 0.0222]], + ['Z', []], + ], + rotateScaleFactor: 28 / 38, +}; + +export const sunnyParams: ShapeParameters = { + path: [ + ['M', [0.7702, 0.1213]], + ['C', [0.8013, 0.1234, 0.8168, 0.1245, 0.8294, 0.13]], + ['C', [0.8476, 0.1379, 0.8621, 0.1524, 0.87, 0.1706]], + ['C', [0.8755, 0.1832, 0.8766, 0.1987, 0.8787, 0.2298]], + ['L', [0.8835, 0.3008]], + ['C', [0.8844, 0.3134, 0.8848, 0.3197, 0.8862, 0.3257]], + ['C', [0.8882, 0.3344, 0.8916, 0.3426, 0.8963, 0.3502]], + ['C', [0.8996, 0.3554, 0.9038, 0.3601, 0.912, 0.3696]], + ['L', [0.9588, 0.4232]], + ['C', [0.9793, 0.4467, 0.9896, 0.4585, 0.9946, 0.4712]], + ['C', [1.0018, 0.4897, 1.0018, 0.5103, 0.9946, 0.5287]], + ['C', [0.9896, 0.5415, 0.9793, 0.5533, 0.9588, 0.5768]], + ['L', [0.912, 0.6303]], + ['C', [0.9038, 0.6398, 0.8996, 0.6446, 0.8963, 0.6498]], + ['C', [0.8916, 0.6573, 0.8882, 0.6656, 0.8862, 0.6743]], + ['C', [0.8848, 0.6803, 0.8844, 0.6866, 0.8835, 0.6992]], + ['L', [0.8787, 0.7702]], + ['C', [0.8766, 0.8013, 0.8755, 0.8168, 0.87, 0.8294]], + ['C', [0.8621, 0.8476, 0.8476, 0.8621, 0.8294, 0.87]], + ['C', [0.8168, 0.8755, 0.8013, 0.8766, 0.7702, 0.8787]], + ['L', [0.6992, 0.8835]], + ['C', [0.6866, 0.8844, 0.6803, 0.8848, 0.6743, 0.8862]], + ['C', [0.6656, 0.8882, 0.6573, 0.8916, 0.6498, 0.8963]], + ['C', [0.6446, 0.8996, 0.6398, 0.9038, 0.6303, 0.912]], + ['L', [0.5768, 0.9588]], + ['C', [0.5533, 0.9793, 0.5415, 0.9896, 0.5287, 0.9946]], + ['C', [0.5103, 1.0018, 0.4897, 1.0018, 0.4712, 0.9946]], + ['C', [0.4585, 0.9896, 0.4467, 0.9793, 0.4232, 0.9588]], + ['L', [0.3696, 0.912]], + ['C', [0.3601, 0.9038, 0.3554, 0.8996, 0.3502, 0.8963]], + ['C', [0.3426, 0.8916, 0.3344, 0.8882, 0.3257, 0.8862]], + ['C', [0.3197, 0.8848, 0.3134, 0.8844, 0.3008, 0.8835]], + ['L', [0.2298, 0.8787]], + ['C', [0.1987, 0.8766, 0.1832, 0.8755, 0.1706, 0.87]], + ['C', [0.1524, 0.8621, 0.1379, 0.8476, 0.13, 0.8294]], + ['C', [0.1245, 0.8168, 0.1234, 0.8013, 0.1213, 0.7702]], + ['L', [0.1165, 0.6992]], + ['C', [0.1156, 0.6866, 0.1152, 0.6803, 0.1138, 0.6743]], + ['C', [0.1118, 0.6656, 0.1084, 0.6573, 0.1037, 0.6498]], + ['C', [0.1004, 0.6446, 0.0962, 0.6398, 0.0879, 0.6303]], + ['L', [0.0412, 0.5768]], + ['C', [0.0207, 0.5533, 0.0104, 0.5415, 0.0054, 0.5287]], + ['C', [-0.0018, 0.5103, -0.0018, 0.4897, 0.0054, 0.4712]], + ['C', [0.0104, 0.4585, 0.0207, 0.4467, 0.0412, 0.4232]], + ['L', [0.0879, 0.3696]], + ['C', [0.0962, 0.3601, 0.1004, 0.3554, 0.1037, 0.3502]], + ['C', [0.1084, 0.3426, 0.1118, 0.3344, 0.1138, 0.3257]], + ['C', [0.1152, 0.3197, 0.1156, 0.3134, 0.1165, 0.3008]], + ['L', [0.1213, 0.2298]], + ['C', [0.1234, 0.1987, 0.1245, 0.1832, 0.13, 0.1706]], + ['C', [0.1379, 0.1524, 0.1524, 0.1379, 0.1706, 0.13]], + ['C', [0.1832, 0.1245, 0.1987, 0.1234, 0.2298, 0.1213]], + ['L', [0.3008, 0.1165]], + ['C', [0.3134, 0.1156, 0.3197, 0.1152, 0.3257, 0.1138]], + ['C', [0.3344, 0.1118, 0.3426, 0.1084, 0.3502, 0.1037]], + ['C', [0.3554, 0.1004, 0.3601, 0.0962, 0.3696, 0.0879]], + ['L', [0.4232, 0.0412]], + ['C', [0.4467, 0.0207, 0.4585, 0.0104, 0.4712, 0.0054]], + ['C', [0.4897, -0.0018, 0.5103, -0.0018, 0.5287, 0.0054]], + ['C', [0.5415, 0.0104, 0.5533, 0.0207, 0.5768, 0.0412]], + ['L', [0.6303, 0.0879]], + ['C', [0.6398, 0.0962, 0.6446, 0.1004, 0.6498, 0.1037]], + ['C', [0.6573, 0.1084, 0.6656, 0.1118, 0.6743, 0.1138]], + ['C', [0.6803, 0.1152, 0.6866, 0.1156, 0.6992, 0.1165]], + ['L', [0.7702, 0.1213]], + ['Z', []], + ], + rotateScaleFactor: 34 / 38, +}; + +export const pillParams: ShapeParameters = { + path: [ + ['M', [0.2662, 0.1259]], + ['C', [0.4341, -0.042, 0.7062, -0.042, 0.8741, 0.1259]], + ['C', [1.042, 0.2938, 1.042, 0.5659, 0.8741, 0.7338]], + ['L', [0.7338, 0.8741]], + ['C', [0.5659, 1.042, 0.2938, 1.042, 0.1259, 0.8741]], + ['C', [-0.042, 0.7062, -0.042, 0.4341, 0.1259, 0.2662]], + ['L', [0.2662, 0.1259]], + ['Z', []], + ], + rotateScaleFactor: 30 / 38, +}; + +export const pentagonParams: ShapeParameters = { + path: [ + ['M', [0.3426, 0.0864]], + ['C', [0.3914, 0.0509, 0.4158, 0.0331, 0.4418, 0.0243]], + ['C', [0.4795, 0.0115, 0.5205, 0.0115, 0.5582, 0.0243]], + ['C', [0.5842, 0.0331, 0.6086, 0.0509, 0.6574, 0.0864]], + ['L', [0.7641, 0.1641]], + ['L', [0.8707, 0.237]], + ['C', [0.9213, 0.2715, 0.9466, 0.2888, 0.9635, 0.3105]], + ['C', [0.988, 0.3419, 1.0008, 0.3806, 1, 0.4201]], + ['C', [0.9994, 0.4474, 0.9893, 0.4759, 0.9692, 0.533]], + ['L', [0.9273, 0.6519]], + ['L', [0.8887, 0.7738]], + ['C', [0.8705, 0.8309, 0.8615, 0.8595, 0.8456, 0.8818]], + ['C', [0.8227, 0.9139, 0.7894, 0.9376, 0.751, 0.9489]], + ['C', [0.7245, 0.9568, 0.6939, 0.9563, 0.6328, 0.9554]], + ['L', [0.5, 0.9534]], + ['L', [0.3672, 0.9554]], + ['C', [0.3061, 0.9563, 0.2755, 0.9568, 0.249, 0.9489]], + ['C', [0.2106, 0.9376, 0.1773, 0.9139, 0.1544, 0.8818]], + ['C', [0.1385, 0.8595, 0.1295, 0.8309, 0.1113, 0.7738]], + ['L', [0.0727, 0.6519]], + ['L', [0.0308, 0.533]], + ['C', [0.0107, 0.4759, 0.0006, 0.4474, 0, 0.4201]], + ['C', [-0.0008, 0.3806, 0.012, 0.3419, 0.0365, 0.3105]], + ['C', [0.0534, 0.2888, 0.0787, 0.2715, 0.1293, 0.237]], + ['L', [0.2359, 0.1641]], + ['L', [0.3426, 0.0864]], + ['Z', []], + ], + rotateScaleFactor: 34 / 38, +}; + +export const cookie9Params: ShapeParameters = { + path: [ + ['M', [0.3914, 0.0472]], + ['C', [0.3968, 0.0428, 0.3995, 0.0406, 0.402, 0.0387]], + ['C', [0.46, -0.0051, 0.54, -0.0051, 0.598, 0.0387]], + ['C', [0.6005, 0.0406, 0.6032, 0.0428, 0.6086, 0.0472]], + ['C', [0.611, 0.0491, 0.6122, 0.0501, 0.6134, 0.051]], + ['C', [0.6406, 0.0725, 0.6741, 0.0846, 0.7088, 0.0857]], + ['C', [0.7103, 0.0858, 0.7119, 0.0858, 0.715, 0.0858]], + ['C', [0.7219, 0.0859, 0.7254, 0.0859, 0.7285, 0.0861]], + ['C', [0.8011, 0.0898, 0.8625, 0.1411, 0.8787, 0.2118]], + ['C', [0.8794, 0.2148, 0.88, 0.2182, 0.8813, 0.2251]], + ['C', [0.8819, 0.2281, 0.8822, 0.2296, 0.8825, 0.2311]], + ['C', [0.8896, 0.265, 0.9074, 0.2958, 0.9333, 0.3189]], + ['C', [0.9344, 0.3199, 0.9356, 0.3209, 0.9379, 0.3229]], + ['C', [0.9432, 0.3274, 0.9458, 0.3297, 0.9481, 0.3318]], + ['C', [1.0014, 0.3812, 1.0153, 0.4598, 0.9821, 0.5244]], + ['C', [0.9807, 0.5271, 0.979, 0.5302, 0.9756, 0.5362]], + ['C', [0.9741, 0.5389, 0.9733, 0.5403, 0.9726, 0.5416]], + ['C', [0.9562, 0.5722, 0.95, 0.6071, 0.955, 0.6414]], + ['C', [0.9552, 0.6429, 0.9555, 0.6445, 0.956, 0.6475]], + ['C', [0.9571, 0.6544, 0.9576, 0.6578, 0.958, 0.6609]], + ['C', [0.967, 0.7328, 0.927, 0.802, 0.86, 0.8302]], + ['C', [0.8572, 0.8314, 0.8539, 0.8327, 0.8474, 0.8351]], + ['C', [0.8445, 0.8362, 0.843, 0.8368, 0.8416, 0.8373]], + ['C', [0.8094, 0.8502, 0.7821, 0.873, 0.7638, 0.9025]], + ['C', [0.763, 0.9038, 0.7622, 0.9051, 0.7606, 0.9078]], + ['C', [0.7571, 0.9137, 0.7553, 0.9167, 0.7536, 0.9193]], + ['C', [0.7142, 0.9802, 0.6389, 1.0075, 0.5694, 0.9862]], + ['C', [0.5665, 0.9853, 0.5632, 0.9841, 0.5566, 0.9818]], + ['C', [0.5537, 0.9808, 0.5522, 0.9803, 0.5508, 0.9798]], + ['C', [0.5178, 0.969, 0.4822, 0.969, 0.4492, 0.9798]], + ['C', [0.4478, 0.9803, 0.4463, 0.9808, 0.4434, 0.9818]], + ['C', [0.4368, 0.9841, 0.4335, 0.9853, 0.4306, 0.9862]], + ['C', [0.3611, 1.0075, 0.2858, 0.9802, 0.2464, 0.9193]], + ['C', [0.2447, 0.9167, 0.2429, 0.9137, 0.2394, 0.9078]], + ['C', [0.2378, 0.9051, 0.237, 0.9038, 0.2362, 0.9025]], + ['C', [0.2179, 0.873, 0.1906, 0.8502, 0.1584, 0.8373]], + ['C', [0.157, 0.8368, 0.1555, 0.8362, 0.1526, 0.8351]], + ['C', [0.1461, 0.8327, 0.1428, 0.8314, 0.14, 0.8302]], + ['C', [0.073, 0.802, 0.033, 0.7328, 0.042, 0.6609]], + ['C', [0.0424, 0.6578, 0.0429, 0.6544, 0.044, 0.6475]], + ['C', [0.0445, 0.6445, 0.0448, 0.6429, 0.045, 0.6414]], + ['C', [0.05, 0.6071, 0.0438, 0.5722, 0.0274, 0.5416]], + ['C', [0.0267, 0.5403, 0.0259, 0.5389, 0.0244, 0.5362]], + ['C', [0.021, 0.5302, 0.0193, 0.5271, 0.0179, 0.5244]], + ['C', [-0.0153, 0.4598, -0.0014, 0.3812, 0.0519, 0.3318]], + ['C', [0.0542, 0.3297, 0.0568, 0.3274, 0.0621, 0.3229]], + ['C', [0.0644, 0.3209, 0.0656, 0.3199, 0.0667, 0.3189]], + ['C', [0.0926, 0.2958, 0.1104, 0.265, 0.1175, 0.2311]], + ['C', [0.1178, 0.2296, 0.1181, 0.2281, 0.1187, 0.2251]], + ['C', [0.12, 0.2182, 0.1206, 0.2148, 0.1213, 0.2118]], + ['C', [0.1375, 0.1411, 0.1989, 0.0898, 0.2715, 0.0861]], + ['C', [0.2746, 0.0859, 0.2781, 0.0859, 0.285, 0.0858]], + ['C', [0.2881, 0.0858, 0.2897, 0.0858, 0.2912, 0.0857]], + ['C', [0.3259, 0.0846, 0.3594, 0.0725, 0.3866, 0.051]], + ['C', [0.3878, 0.0501, 0.389, 0.0491, 0.3914, 0.0472]], + ['Z', []], + ], + rotateScaleFactor: 34 / 38, +}; + +export const softBurstParams: ShapeParameters = { + path: [ + ['M', [0.4549, 0.0247]], + ['C', [0.4757, -0.0082, 0.5243, -0.0082, 0.545, 0.0247]], + ['L', [0.5947, 0.1036]], + ['C', [0.6084, 0.1253, 0.6359, 0.1341, 0.6598, 0.1245]], + ['L', [0.7471, 0.0894]], + ['C', [0.7835, 0.0747, 0.8229, 0.1029, 0.82, 0.1416]], + ['L', [0.8131, 0.2342]], + ['C', [0.8112, 0.2596, 0.8282, 0.2827, 0.8533, 0.2887]], + ['L', [0.9449, 0.3109]], + ['C', [0.9831, 0.3202, 0.9981, 0.3657, 0.9727, 0.3953]], + ['L', [0.9119, 0.4663]], + ['C', [0.8952, 0.4858, 0.8952, 0.5142, 0.9119, 0.5337]], + ['L', [0.9727, 0.6047]], + ['C', [0.9981, 0.6343, 0.9831, 0.6798, 0.9449, 0.6891]], + ['L', [0.8533, 0.7113]], + ['C', [0.8282, 0.7173, 0.8112, 0.7404, 0.8131, 0.7658]], + ['L', [0.82, 0.8584]], + ['C', [0.8229, 0.8971, 0.7835, 0.9252, 0.7471, 0.9106]], + ['L', [0.6598, 0.8755]], + ['C', [0.6359, 0.8659, 0.6084, 0.8747, 0.5947, 0.8964]], + ['L', [0.545, 0.9753]], + ['C', [0.5243, 1.0082, 0.4757, 1.0082, 0.4549, 0.9753]], + ['L', [0.4053, 0.8964]], + ['C', [0.3916, 0.8747, 0.3641, 0.8659, 0.3402, 0.8755]], + ['L', [0.2529, 0.9106]], + ['C', [0.2165, 0.9252, 0.1771, 0.8971, 0.18, 0.8584]], + ['L', [0.1869, 0.7658]], + ['C', [0.1888, 0.7404, 0.1718, 0.7173, 0.1467, 0.7113]], + ['L', [0.0551, 0.6891]], + ['C', [0.0169, 0.6798, 0.0019, 0.6343, 0.0273, 0.6047]], + ['L', [0.0881, 0.5337]], + ['C', [0.1048, 0.5142, 0.1048, 0.4858, 0.0881, 0.4663]], + ['L', [0.0273, 0.3953]], + ['C', [0.0019, 0.3657, 0.0169, 0.3202, 0.0551, 0.3109]], + ['L', [0.1467, 0.2887]], + ['C', [0.1718, 0.2827, 0.1888, 0.2596, 0.1869, 0.2342]], + ['L', [0.18, 0.1416]], + ['C', [0.1771, 0.1029, 0.2165, 0.0747, 0.2529, 0.0894]], + ['L', [0.3402, 0.1245]], + ['C', [0.3641, 0.1341, 0.3916, 0.1253, 0.4053, 0.1036]], + ['L', [0.4549, 0.0247]], + ['Z', []], + ], + rotateScaleFactor: 34 / 38, +}; + +/** + * Список всех форм + */ +export const shapeList = [ + ovalParams, + pillParams, + pentagonParams, + sunnyParams, + cookie4Params, + cookie9Params, + softBurstParams, +] as const; + +/** + * Название форм + */ +export const shapeNameMap: ReadonlyWeakMap = new WeakMap([ + [ovalParams, 'Oval'], + [pillParams, 'Pill'], + [pentagonParams, 'Pentagon'], + [sunnyParams, 'Sunny'], + [cookie4Params, '4-sided cookie'], + [cookie9Params, '9-sided cookie'], + [softBurstParams, 'Soft burst'], +]); diff --git a/packages/vkui/src/lib/math.test.ts b/packages/vkui/src/lib/math.test.ts new file mode 100644 index 00000000000..0ad867d8b78 --- /dev/null +++ b/packages/vkui/src/lib/math.test.ts @@ -0,0 +1,9 @@ +import { distance, distancePoint } from './math'; + +it('distance', () => { + expect(distance(1, 1, 4, 5)).toBeCloseTo(5); +}); + +it('distancePoint', () => { + expect(distancePoint([1, 1], [4, 5])).toBeCloseTo(5); +}); diff --git a/packages/vkui/src/lib/math.ts b/packages/vkui/src/lib/math.ts new file mode 100644 index 00000000000..53f29f08bc8 --- /dev/null +++ b/packages/vkui/src/lib/math.ts @@ -0,0 +1,37 @@ +/** + * Длина гипотенузы + */ +function hypotenuse(a: number, b: number): number { + return Math.sqrt(a * a + b * b); +} + +/** + * Евклидово расстояние между двумя точками + * + * ```ts + * import { distancePoint } from './math'; + * import * as assert from 'node:assert/strict'; + * + * assert.equal(distance(1, 1, 4, 5), 5) + * ``` + */ +export function distance(x1: number, y1: number, x2: number, y2: number): number { + return hypotenuse(x1 - x2, y1 - y2); +} + +/** + * Евклидово расстояние между двумя точками + * + * ```ts + * import { distancePoint } from './math'; + * import * as assert from 'node:assert/strict'; + * + * assert.equal(distancePoint([1, 1], [4, 5]), 5) + * ``` + */ +export function distancePoint( + [x1, y1]: readonly [number, number], + [x2, y2]: readonly [number, number], +): number { + return distance(x1, y1, x2, y2); +} diff --git a/packages/vkui/src/lib/svg/path/approximate.test.ts b/packages/vkui/src/lib/svg/path/approximate.test.ts new file mode 100644 index 00000000000..04cee55806e --- /dev/null +++ b/packages/vkui/src/lib/svg/path/approximate.test.ts @@ -0,0 +1,30 @@ +import * as approximate from './approximate'; + +const svgPath = [ + ['M', [1, 2]], + ['L', [3, 4]], + ['C', [1, 1, 1, 1, 4, 4]], + ['Z', []], +] as const; + +describe('approximate.copySVGPath', () => { + it('defaultOption', () => { + const result = approximate.approximateRing(svgPath); + expect(result).toStrictEqual([ + [1, 2], + [3, 4], + [3, 4], + [4, 4], + ]); + }); + + it('with maxSegmentLength', () => { + const result = approximate.approximateRing(svgPath, { maxSegmentLength: 1 }); + expect(result).toStrictEqual([ + [1, 2], + [3, 4], + [3, 4], + [4, 4], + ]); + }); +}); diff --git a/packages/vkui/src/lib/svg/path/approximate.ts b/packages/vkui/src/lib/svg/path/approximate.ts new file mode 100644 index 00000000000..e258a2c2b1f --- /dev/null +++ b/packages/vkui/src/lib/svg/path/approximate.ts @@ -0,0 +1,81 @@ +import { type DeepReadonly } from '../../../types'; +import * as array from '../../array'; +import { cubicBezierTwoDimensional } from '../../curve'; +import { distancePoint } from '../../math'; +import type { SVGPathSupport } from './path'; +import { type Point, type Points } from './point'; + +export interface MaxSegmentLengthOptions { + maxSegmentLength?: number; +} + +/** + * Аппроксимирует кривую Безье в точки + */ +function approximateCurves( + prevPoint: [number, number], + args: [number, number, number, number, number, number], + { maxSegmentLength }: Required, +): Points { + const distancePoints = distancePoint([args[4], args[5]], prevPoint); + const acc: Points = new Array(Math.ceil(Math.max(2, distancePoints / maxSegmentLength))); + + for (let index = 0; index < acc.length; index++) { + const progress = Math.min(1, (maxSegmentLength / distancePoints) * index); + + acc[index] = cubicBezierTwoDimensional( + prevPoint[0], + prevPoint[1], + args[0], + args[1], + args[2], + args[3], + args[4], + args[5], + progress, + ); + } + + return acc; +} + +/** + * Аппроксимирует svg путь в точки + * + * ```ts + * import * as approximate from './approximate'; + * import * as assert from 'node:assert/strict'; + * + * assert.equal( + * approximate.approximateRing([['M', [20, 40]], ['L', [40, 60]]]), + * [[20, 40], [40, 60]], + * ); + * ``` + */ +export function approximateRing( + path: DeepReadonly, + { maxSegmentLength = 1 }: MaxSegmentLengthOptions = {}, +): Points { + const prevPoint: Point = [0, 0]; + + return path.reduce((acc, [command, args]) => { + switch (command) { + case 'M': + case 'L': + acc.push(array.copy(args)); + prevPoint[0] = args[0]; + prevPoint[1] = args[1]; + break; + case 'C': + acc.push(...approximateCurves(prevPoint, array.copy(args), { maxSegmentLength })); + prevPoint[0] = args[4]; + prevPoint[1] = args[5]; + + break; + case 'Z': + break; + } + + return acc; + }, []); +} diff --git a/packages/vkui/src/lib/svg/path/interpolate.test.ts b/packages/vkui/src/lib/svg/path/interpolate.test.ts new file mode 100644 index 00000000000..4d3116987bf --- /dev/null +++ b/packages/vkui/src/lib/svg/path/interpolate.test.ts @@ -0,0 +1,68 @@ +import * as shapes from '../../material/shapes/shapes'; +import * as interpolate from './interpolate'; + +describe('interpolate.interpolate', () => { + it('default option', () => { + const from = shapes.shape(shapes.ovalParams, 10); + const to = shapes.shape(shapes.pillParams, 10); + + const fn = interpolate.interpolate(from, to); + + const result0 = fn(0); + const resultHalf = fn(0.5); + const result1 = fn(1); + + expect(result0).toStrictEqual(from); + expect(result0).not.toBe(from); + + expect(result1).toStrictEqual(to); + expect(result1).not.toBe(to); + + expect(resultHalf.length).toBeGreaterThan(0); + }); + + it('maxSegmentLength: 1', () => { + const from = shapes.shape(shapes.ovalParams, 10); + const to = shapes.shape(shapes.pillParams, 10); + + const fn = interpolate.interpolate(from, to, { maxSegmentLength: 1 }); + + const result0 = fn(0); + const resultHalf = fn(0.5); + const result1 = fn(1); + + expect(result0).toStrictEqual(from); + expect(result0).not.toBe(from); + + expect(result1).toStrictEqual(to); + expect(result1).not.toBe(to); + + expect(resultHalf.length).toBeGreaterThan(0); + }); + + it('copy point', () => { + const from = [ + ['M', [0, 0]], + ['L', [0, 0]], + ['L', [0, 0]], + ] as const; + const to = [ + ['M', [0, 0]], + ['L', [0, 0]], + ] as const; + + const fn = interpolate.interpolate(from, to, { maxSegmentLength: 1 }); + + const result0 = fn(0); + const resultHalf = fn(0.5); + const result1 = fn(1); + + expect(result0).toStrictEqual(from); + expect(result0).not.toBe(from); + + expect(result1).toStrictEqual(to); + expect(result1).not.toBe(to); + + expect(resultHalf.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/vkui/src/lib/svg/path/interpolate.ts b/packages/vkui/src/lib/svg/path/interpolate.ts new file mode 100644 index 00000000000..62cadec5e50 --- /dev/null +++ b/packages/vkui/src/lib/svg/path/interpolate.ts @@ -0,0 +1,151 @@ +import { type DeepReadonly } from '../../../types'; +import * as array from '../../array'; +import { distancePoint } from '../../math'; +import { approximateRing, type MaxSegmentLengthOptions } from './approximate'; +import { copySVGPath, type DrawtoCommandSupport, type SVGPathSupport } from './path'; +import { type Point, type Points } from './point'; + +function pointAlong([x1, y1]: Readonly, [x2, y2]: Readonly, progress: number): Point { + return [x1 + (x2 - x1) * progress, y1 + (y2 - y1) * progress]; +} + +function polygonLength(points: Points): number { + return points.reduce( + (acc, point, index) => acc + distancePoint(point, points[(index + 1) % points.length]), + 0, + ); +} + +function addPoints(path: Points, numPoints: number): Points { + if (numPoints === 0) { + return path; + } + + const desiredLength = path.length + numPoints; + + const step = polygonLength(path) / numPoints; + + let index = 0; + let cursor = 0; + let insertAt = step / 2; + + while (path.length < desiredLength) { + const element = path[index]; + const nextElement = path[(index + 1) % path.length]; + + const segment = distancePoint(element, nextElement); + + if (insertAt <= cursor + segment) { + path.splice( + index + 1, + 0, + segment ? pointAlong(element, nextElement, insertAt - cursor) : array.copy(element), // TODO: test coverage + ); + insertAt += step; + continue; + } + + cursor += segment; + index++; + } + + return path; +} + +function bisect(path: Points, { maxSegmentLength }: Required): Points { + for (let index = 0; index < path.length; index++) { + const element = path[index]; + let nextElement = path[(index + 1) % path.length]; + + while (distancePoint(element, nextElement) > maxSegmentLength) { + nextElement = pointAlong(element, nextElement, 0.5); + + path.splice(index + 1, 0, nextElement); + } + } + + return path; +} + +function normalizeRing( + path: DeepReadonly, + { maxSegmentLength }: Required, +): Points { + return bisect(approximateRing(path, { maxSegmentLength }), { maxSegmentLength }); +} + +function rotate(from: Points, to: Points) { + let min = Infinity; + let bestOffset = 0; + + for (let offset = 0; offset < from.length; offset++) { + let sumOfSquares = 0; + + to.forEach((element, index) => { + const d = distancePoint(from[(offset + index) % from.length], element); + sumOfSquares += d * d; + }); + + if (sumOfSquares < min) { + min = sumOfSquares; + bestOffset = offset; + } + } + + if (bestOffset) { + const spliced = from.splice(0, bestOffset); + from.splice(from.length, 0, ...spliced); + } +} + +function interpolatePoint(from: Readonly, to: Readonly): (progress: number) => Point { + return (progress: number) => { + return [from[0] + progress * (to[0] - from[0]), from[1] + progress * (to[1] - from[1])]; + }; +} + +function interpolatePoints(from: Points, to: Points): (progress: number) => SVGPathSupport { + const interpolators = from.map((point, index) => interpolatePoint(point, to[index])); + + return (progress: number) => { + const result = interpolators.map((interpolator, index) => [ + index === 0 ? 'M' : 'L', + interpolator(progress), + ]); + result.push(['Z', []]); + + return result; + }; +} + +function interpolateRing(from: Points, to: Points): (progress: number) => SVGPathSupport { + const diff = from.length - to.length; + + const fromRing = addPoints(from, -Math.min(diff, 0)); + const toRing = addPoints(to, Math.max(diff, 0)); + + rotate(fromRing, toRing); + + return interpolatePoints(fromRing, toRing); +} + +export function interpolate( + from: DeepReadonly, + to: DeepReadonly, + { maxSegmentLength = 1 }: MaxSegmentLengthOptions = {}, +): (progress: number) => SVGPathSupport { + const fromRing = normalizeRing(from, { maxSegmentLength }); + const toRing = normalizeRing(to, { maxSegmentLength }); + + const fn = interpolateRing(fromRing, toRing); + + return (progress: number) => { + if (progress < 1e-4) { + return copySVGPath(from); + } else if (progress > 1 - 1e-4) { + return copySVGPath(to); + } + + return fn(progress); + }; +} diff --git a/packages/vkui/src/lib/svg/path/path.test.ts b/packages/vkui/src/lib/svg/path/path.test.ts new file mode 100644 index 00000000000..cb5278e3b5b --- /dev/null +++ b/packages/vkui/src/lib/svg/path/path.test.ts @@ -0,0 +1,18 @@ +import * as path from './path'; + +const svgPath = [ + ['M', [10, 20]], + ['L', [30, 40]], + ['Z', []], +] as const; + +it('path.copySVGPath', () => { + const result = path.copySVGPath(svgPath); + expect(result).toStrictEqual(svgPath); + expect(result).not.toBe(svgPath); +}); + +it('path.svgPathToString', () => { + const result = path.svgPathToString(svgPath); + expect(result).toBe('M10.0000,20.0000L30.0000,40.0000Z'); +}); diff --git a/packages/vkui/src/lib/svg/path/path.ts b/packages/vkui/src/lib/svg/path/path.ts new file mode 100644 index 00000000000..6cf4a6f846a --- /dev/null +++ b/packages/vkui/src/lib/svg/path/path.ts @@ -0,0 +1,102 @@ +import type { DeepReadonly, DeepWriteable } from '../../../types'; +import * as array from '../../array'; + +type Flag = 0 | 1; + +export type CoordinatePair = [number, number]; +export type Coordinate = [number]; +export type CurvetoCoordinate = [number, number, number, number, number, number]; +export type SmoothCurvetoCoordinate = [number, number, number, number]; +export type QuadraticCurvetoCoordinate = [number, number, number, number]; +export type EllipticalArcArgument = [number, number, number, Flag, Flag, number, number]; + +export type DrawtoCommandAbsolute = + | ['M', CoordinatePair] + | ['L', CoordinatePair] + | ['H', Coordinate] + | ['V', Coordinate] + | ['C', CurvetoCoordinate] + | ['S', SmoothCurvetoCoordinate] + | ['Q', QuadraticCurvetoCoordinate] + | ['T', CoordinatePair] + | ['A', EllipticalArcArgument] + | ['Z', []]; + +type DrawtoCommandRelative = + | ['m', CoordinatePair] + | ['l', CoordinatePair] + | ['h', Coordinate] + | ['v', Coordinate] + | ['c', CurvetoCoordinate] + | ['s', SmoothCurvetoCoordinate] + | ['q', QuadraticCurvetoCoordinate] + | ['t', CoordinatePair] + | ['a', EllipticalArcArgument] + | ['z', []]; + +export type DrawtoCommand = DrawtoCommandAbsolute | DrawtoCommandRelative; + +export type SVGPath = DrawtoCommand[]; + +export type DrawtoCommandSupport = + | ['M', CoordinatePair] + | ['L', CoordinatePair] + | ['C', CurvetoCoordinate] + | ['Z', []]; + +/** + * Поддерживаем только `M`, `L`, `C`, и `Z` команды для оптимизации размера кода + */ +export type SVGPathSupport = DrawtoCommandSupport[]; + +/** + * Преобразует `SVGPath` в строку + * + * ```ts + * import * as path from './path'; + * import * as assert from 'node:assert/strict'; + * + * assert.equal( + * path.svgPathToString([['M', [20, 40]], ['L', [40, 60]]]), + * 'M20,40L40,60', + * ); + * ``` + */ +export function svgPathToString(svgPath: DeepReadonly): string { + // return svgPath.reduce((acc, value) => acc + value[0] + value[1].join(','), ''); + + let value = ''; + + // WARNING: При изменении кода рекомендуется проверить изменение + // времени исполнения на компоненте ExpressiveSpinner в профайлерах + for (let index = 0; index < svgPath.length; index++) { + value += svgPath[index][0]; + for (let valueIndex = 0; valueIndex < svgPath[index][1].length - 1; valueIndex++) { + value += svgPath[index][1][valueIndex].toFixed(4) + ','; + } + + if (svgPath[index][1].length) { + value += svgPath[index][1][svgPath[index][1].length - 1].toFixed(4); + } + } + + return value; +} + +/** + * Копирует массив команд для пути + * + * ```ts + * import * as path from './path'; + * import * as assert from 'node:assert/strict'; + * + * const originalSVGPath = [['M', [20, 40]], ['L', [40, 60]]]; + * const copiedSVGPath = path.copy(originalSVGPath); + * + * assert.deepEqual(copiedSVGPath, originalSVGPath); + * assert.notEqual(copiedSVGPath, originalSVGPath); + * ``` + */ +export function copySVGPath>(svgPath: T): DeepWriteable { + return svgPath.map((value) => [value[0], array.copy(value[1])]) as DeepWriteable; +} diff --git a/packages/vkui/src/lib/svg/path/point.ts b/packages/vkui/src/lib/svg/path/point.ts new file mode 100644 index 00000000000..ff5cc955bf0 --- /dev/null +++ b/packages/vkui/src/lib/svg/path/point.ts @@ -0,0 +1,2 @@ +export type Point = [number, number]; +export type Points = Point[]; diff --git a/packages/vkui/src/lib/svg/path/transform.test.ts b/packages/vkui/src/lib/svg/path/transform.test.ts new file mode 100644 index 00000000000..1bd95204881 --- /dev/null +++ b/packages/vkui/src/lib/svg/path/transform.test.ts @@ -0,0 +1,55 @@ +import * as transform from './transform'; + +const svgPath = [ + ['M', [10, 20]], + ['L', [30, 40]], + ['Z', []], +] as const; + +describe('transform.scale', () => { + it('2,2', () => { + expect(transform.scale(svgPath, 2, 2)).toStrictEqual([ + ['M', [20, 40]], + ['L', [60, 80]], + ['Z', []], + ]); + }); + + it('1,1', () => { + const result = transform.scale(svgPath, 1, 1); + expect(result).toStrictEqual(svgPath); + expect(result).not.toBe(svgPath); + }); +}); + +describe('transform.translate', () => { + it('10,20', () => { + expect(transform.translate(svgPath, 10, 20)).toStrictEqual([ + ['M', [20, 40]], + ['L', [40, 60]], + ['Z', []], + ]); + }); + + it('0,0', () => { + const result = transform.translate(svgPath, 0, 0); + expect(result).toStrictEqual(svgPath); + expect(result).not.toBe(svgPath); + }); +}); + +describe('transform.rotate', () => { + it('10,20', () => { + expect(transform.rotate(svgPath, 30, 30, 180)).toStrictEqual([ + ['M', [50, 40]], + ['L', [30, 20]], + ['Z', []], + ]); + }); + + it('0,0', () => { + const result = transform.rotate(svgPath, 10, 20, 0); + expect(result).toStrictEqual(svgPath); + expect(result).not.toBe(svgPath); + }); +}); diff --git a/packages/vkui/src/lib/svg/path/transform.ts b/packages/vkui/src/lib/svg/path/transform.ts new file mode 100644 index 00000000000..8bb23d9bb2f --- /dev/null +++ b/packages/vkui/src/lib/svg/path/transform.ts @@ -0,0 +1,147 @@ +import { type DeepReadonly } from '../../../types'; +import * as array from '../../array'; +import { + type CoordinatePair, + copySVGPath, + type CurvetoCoordinate, + type DrawtoCommandSupport, + type SVGPathSupport, +} from './path'; + +type TransformFn = (values: Readonly) => T; + +function transformDrawtoCommand( + drawtoCommand: DeepReadonly, + fn: TransformFn, +): DrawtoCommandSupport { + if (drawtoCommand[0] === 'Z') { + return ['Z', []]; + } + + return [drawtoCommand[0], fn(drawtoCommand[1])] as DrawtoCommandSupport; +} + +function transformSVGPath(path: DeepReadonly, fn: TransformFn): SVGPathSupport { + return path.map((item) => transformDrawtoCommand(item, fn)); +} + +/** + * Масштабирует путь. + * + * ```ts + * import * as transform from './transform'; + * import * as assert from 'node:assert/strict'; + * + * const scalePath = transform.scale( + * [['M', [10, 20]], ['L', [30, 40]]], + * 2, + * 2, + * ); + * + * assert.deepEqual( + * scalePath, + * [['M', [20, 40]], ['L', [60, 80]]], + * ); + * ``` + */ +export function scale( + path: DeepReadonly, + scaleX: number, + scaleY: number, +): SVGPathSupport { + if (scaleX === 1 && scaleY === 1) { + return copySVGPath(path); + } + + const scaleTransform = (values: Readonly): T => + values.map((value, index) => value * (index % 2 === 0 ? scaleX : scaleY)) as T; + + return transformSVGPath(path, scaleTransform); +} + +/** + * Перемещает путь по осям X и Y. + * + * ```ts + * import * as transform from './transform'; + * import * as assert from 'node:assert/strict'; + * + * const translatePath = transform.translate( + * [['M', [10, 20]], ['L', [30, 40]]], + * 10, + * 20, + * ); + * + * assert.deepEqual( + * translatePath, + * [['M', [20, 40]], ['L', [40, 60]]], + * ); + * ``` + */ +export function translate( + path: DeepReadonly, + translateX: number, + translateY: number, +): SVGPathSupport { + if (translateX === 0 && translateY === 0) { + return copySVGPath(path); + } + + const translateTransform = (values: Readonly): T => + values.map((value, index) => value + (index % 2 === 0 ? translateX : translateY)) as T; + + return transformSVGPath(path, translateTransform); +} + +/** + * Вращает путь относительно заданной точки на заданный угол + * + * ```ts + * import * as transform from './transform'; + * import * as assert from 'node:assert/strict'; + * + * const rotatePath = transform.rotate( + * [['M', [10, 20]], ['L', [30, 40]]], + * 30, + * 30, + * 180 + * ); + * + * assert.deepEqual( + * rotatePath, + * [['M', [20, 40]], ['L', [60, 80]]], + * ); + * ``` + */ +export function rotate( + path: DeepReadonly, + originX: number, + originY: number, + angle: number, +): SVGPathSupport { + angle %= 360; + if (angle === 0) { + return copySVGPath(path); + } + + const rad = (angle * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const rotateTransformAbsolute = (originValues: Readonly): T => { + const values = array.copy(originValues); + + for (let i = 0; i < values.length; i += 2) { + const px = values[i]; + const py = values[i + 1]; + const qx = originX + (px - originX) * cos - (py - originY) * sin; + const qy = originY + (px - originX) * sin + (py - originY) * cos; + values[i] = qx; + values[i + 1] = qy; + } + + return values; + }; + + return transformSVGPath(path, rotateTransformAbsolute); +} diff --git a/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts b/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts index 5ef72184dd8..9a94be97cc0 100644 --- a/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts +++ b/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts @@ -1,4 +1,5 @@ import { getFirstTouchEventData } from '../dom'; +import { distance } from '../math'; export type Direction = { axis: 'x' | 'y'; direction: -1 | 1 | null }; @@ -45,8 +46,7 @@ export class UIPanGestureRecognizer { } distance(): number { - const { x, y } = this.delta(); - return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + return distance(this.x1, this.y1, this.x2, this.y2); } velocity(): Coords { diff --git a/packages/vkui/src/types.ts b/packages/vkui/src/types.ts index d7d8a7beafe..74ca9b5d287 100644 --- a/packages/vkui/src/types.ts +++ b/packages/vkui/src/types.ts @@ -132,3 +132,19 @@ export type HasOnlyExpectedProps = { }; export type TimeoutId = ReturnType | null; + +export type DeepReadonly = Readonly<{ + [K in keyof T]: T[K] extends number | string | symbol // Is it a primitive? Then make it readonly + ? Readonly + : // Is it an array of items? Then make the array readonly and the item as well + T[K] extends any[] + ? Readonly> + : // It is some other object, make it readonly as well + DeepReadonly; +}>; + +export type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; + +export interface ReadonlyWeakMap extends Pick, 'get' | 'has'> { + readonly [Symbol.toStringTag]: string; +}