Add basic animation support to the mandala page (#75)
This addresses part of #71 by adding some basic animation support to the mandala page.pull/77/head
rodzic
be8b4ec04b
commit
6136253cd6
|
@ -114,6 +114,10 @@ ul.navbar li:last-child {
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar .disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.sidebar label.checkbox {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
|
|
|
@ -1,33 +1,60 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
type AutoSizingSvgProps = {
|
||||
padding: number;
|
||||
bgColor?: string;
|
||||
sizeToElement?: React.RefObject<HTMLElement>;
|
||||
children: JSX.Element | JSX.Element[];
|
||||
};
|
||||
|
||||
function useResizeHandler(onResize: () => void) {
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An SVG element with an optional background color that
|
||||
* automatically sizes itself to its contents.
|
||||
* automatically sizes itself to either its contents, or
|
||||
* if the `sizeToElement` prop is provided, to the given
|
||||
* container.
|
||||
*/
|
||||
export const AutoSizingSvg = React.forwardRef(
|
||||
(
|
||||
props: {
|
||||
padding: number;
|
||||
bgColor?: string;
|
||||
children: JSX.Element | JSX.Element[];
|
||||
},
|
||||
ref: React.ForwardedRef<SVGSVGElement>
|
||||
) => {
|
||||
const { bgColor, padding } = props;
|
||||
(props: AutoSizingSvgProps, ref: React.ForwardedRef<SVGSVGElement>) => {
|
||||
const { bgColor, padding, sizeToElement } = props;
|
||||
const [x, setX] = useState(0);
|
||||
const [y, setY] = useState(0);
|
||||
const [width, setWidth] = useState(1);
|
||||
const [height, setHeight] = useState(1);
|
||||
const gRef = useRef<SVGGElement>(null);
|
||||
const resizeToElement = () => {
|
||||
if (sizeToElement?.current) {
|
||||
const bbox = sizeToElement.current.getBoundingClientRect();
|
||||
setX(-bbox.width / 2);
|
||||
setY(-bbox.height / 2);
|
||||
setWidth(bbox.width);
|
||||
setHeight(bbox.height);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useResizeHandler(resizeToElement);
|
||||
|
||||
useEffect(() => {
|
||||
const svgEl = gRef.current;
|
||||
if (svgEl) {
|
||||
const bbox = svgEl.getBBox();
|
||||
setX(bbox.x - padding);
|
||||
setY(bbox.y - padding);
|
||||
setWidth(bbox.width + padding * 2);
|
||||
setHeight(bbox.height + padding * 2);
|
||||
if (!resizeToElement()) {
|
||||
const svgEl = gRef.current;
|
||||
if (svgEl) {
|
||||
const bbox = svgEl.getBBox();
|
||||
setX(bbox.x - padding);
|
||||
setY(bbox.y - padding);
|
||||
setWidth(bbox.width + padding * 2);
|
||||
setHeight(bbox.height + padding * 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
import { float, NumericRange, slugify } from "./util";
|
||||
|
||||
|
@ -7,13 +8,18 @@ export type NumericSliderProps = NumericRange & {
|
|||
onChange: (value: number) => void;
|
||||
value: number;
|
||||
valueSuffix?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const NumericSlider: React.FC<NumericSliderProps> = (props) => {
|
||||
const id = props.id || slugify(props.label);
|
||||
|
||||
return (
|
||||
<div className="thingy numeric-slider">
|
||||
<div
|
||||
className={classNames("thingy", "numeric-slider", {
|
||||
disabled: props.disabled,
|
||||
})}
|
||||
>
|
||||
<label htmlFor={id}>{props.label}: </label>
|
||||
<span className="slider">
|
||||
<input
|
||||
|
@ -23,6 +29,7 @@ export const NumericSlider: React.FC<NumericSliderProps> = (props) => {
|
|||
max={props.max}
|
||||
value={props.value}
|
||||
step={props.step}
|
||||
disabled={props.disabled}
|
||||
onChange={(e) => props.onChange(float(e.target.value))}
|
||||
/>
|
||||
<span>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { AutoSizingSvg } from "../auto-sizing-svg";
|
||||
import { getBoundingBoxCenter } from "../bounding-box";
|
||||
import { ExportWidget } from "../export-svg";
|
||||
|
@ -36,6 +36,7 @@ type ExtendedMandalaCircleParams = MandalaCircleParams & {
|
|||
rotation: number;
|
||||
symbolScaling: number;
|
||||
symbolRotation: number;
|
||||
animateSymbolRotation: boolean;
|
||||
};
|
||||
|
||||
const CIRCLE_1_DEFAULTS: ExtendedMandalaCircleParams = {
|
||||
|
@ -47,6 +48,7 @@ const CIRCLE_1_DEFAULTS: ExtendedMandalaCircleParams = {
|
|||
symbolScaling: 1,
|
||||
symbolRotation: 0,
|
||||
invertEveryOtherSymbol: false,
|
||||
animateSymbolRotation: false,
|
||||
};
|
||||
|
||||
const CIRCLE_2_DEFAULTS: ExtendedMandalaCircleParams = {
|
||||
|
@ -58,6 +60,7 @@ const CIRCLE_2_DEFAULTS: ExtendedMandalaCircleParams = {
|
|||
symbolScaling: 1,
|
||||
symbolRotation: 0,
|
||||
invertEveryOtherSymbol: false,
|
||||
animateSymbolRotation: false,
|
||||
};
|
||||
|
||||
const RADIUS: NumericRange = {
|
||||
|
@ -174,6 +177,25 @@ const ExtendedMandalaCircle: React.FC<
|
|||
);
|
||||
};
|
||||
|
||||
function animateMandalaCircleParams(
|
||||
value: ExtendedMandalaCircleParams,
|
||||
frameNumber: number
|
||||
): ExtendedMandalaCircleParams {
|
||||
if (value.animateSymbolRotation) {
|
||||
value = {
|
||||
...value,
|
||||
symbolRotation: frameNumber % ROTATION.max,
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isAnyMandalaCircleAnimated(
|
||||
values: ExtendedMandalaCircleParams[]
|
||||
): boolean {
|
||||
return values.some((value) => value.animateSymbolRotation);
|
||||
}
|
||||
|
||||
const ExtendedMandalaCircleParamsWidget: React.FC<{
|
||||
idPrefix: string;
|
||||
value: ExtendedMandalaCircleParams;
|
||||
|
@ -226,10 +248,18 @@ const ExtendedMandalaCircleParamsWidget: React.FC<{
|
|||
<NumericSlider
|
||||
id={`${idPrefix}symbolRotation`}
|
||||
label="Symbol rotation"
|
||||
disabled={value.animateSymbolRotation}
|
||||
value={value.symbolRotation}
|
||||
onChange={(symbolRotation) => onChange({ ...value, symbolRotation })}
|
||||
{...ROTATION}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Animate symbol rotation"
|
||||
value={value.animateSymbolRotation}
|
||||
onChange={(animateSymbolRotation) =>
|
||||
onChange({ ...value, animateSymbolRotation })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Invert every other symbol (applies only to circles with an even number of symbols)"
|
||||
value={value.invertEveryOtherSymbol}
|
||||
|
@ -250,8 +280,31 @@ function getRandomCircleParams(rng: Random): MandalaCircleParams {
|
|||
};
|
||||
}
|
||||
|
||||
function useAnimation(isEnabled: boolean): number {
|
||||
const [frameNumber, setFrameNumber] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) {
|
||||
setFrameNumber(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = () => {
|
||||
setFrameNumber(frameNumber + 1);
|
||||
};
|
||||
const timeout = requestAnimationFrame(callback);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(timeout);
|
||||
};
|
||||
}, [isEnabled, frameNumber]);
|
||||
|
||||
return frameNumber;
|
||||
}
|
||||
|
||||
export const MandalaPage: React.FC<{}> = () => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [circle1, setCircle1] = useState(CIRCLE_1_DEFAULTS);
|
||||
const [circle2, setCircle2] = useState(CIRCLE_2_DEFAULTS);
|
||||
const [baseCompCtx, setBaseCompCtx] = useState(createSvgCompositionContext());
|
||||
|
@ -263,18 +316,27 @@ export const MandalaPage: React.FC<{}> = () => {
|
|||
setCircle1({ ...circle1, ...getRandomCircleParams(rng) });
|
||||
setCircle2({ ...circle2, ...getRandomCircleParams(rng) });
|
||||
};
|
||||
|
||||
const isAnimated = isAnyMandalaCircleAnimated([circle1, circle2]);
|
||||
const frameNumber = useAnimation(isAnimated);
|
||||
const symbolCtx = noFillIfShowingSpecs(baseCompCtx);
|
||||
|
||||
const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx;
|
||||
|
||||
const circles = [
|
||||
<ExtendedMandalaCircle key="first" {...circle1} {...symbolCtx} />,
|
||||
<ExtendedMandalaCircle
|
||||
key="first"
|
||||
{...animateMandalaCircleParams(circle1, frameNumber)}
|
||||
{...symbolCtx}
|
||||
/>,
|
||||
];
|
||||
|
||||
if (useTwoCircles) {
|
||||
circles.push(
|
||||
<ExtendedMandalaCircle key="second" {...circle2} {...circle2SymbolCtx} />
|
||||
<ExtendedMandalaCircle
|
||||
key="second"
|
||||
{...animateMandalaCircleParams(circle2, frameNumber)}
|
||||
{...circle2SymbolCtx}
|
||||
/>
|
||||
);
|
||||
if (firstBehindSecond) {
|
||||
circles.reverse();
|
||||
|
@ -330,12 +392,14 @@ export const MandalaPage: React.FC<{}> = () => {
|
|||
<div
|
||||
className="canvas"
|
||||
style={{ backgroundColor: baseCompCtx.background }}
|
||||
ref={canvasRef}
|
||||
>
|
||||
<HoverDebugHelper>
|
||||
<AutoSizingSvg
|
||||
padding={20}
|
||||
ref={svgRef}
|
||||
bgColor={baseCompCtx.background}
|
||||
sizeToElement={canvasRef}
|
||||
>
|
||||
<SvgTransform transform={svgScale(0.5)}>{circles}</SvgTransform>
|
||||
</AutoSizingSvg>
|
||||
|
|
|
@ -2703,6 +2703,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"classnames": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
|
||||
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
|
||||
},
|
||||
"cli-cursor": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"@types/react-dom": "^17.0.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"cheerio": "^1.0.0-rc.5",
|
||||
"classnames": "^2.3.1",
|
||||
"gh-pages": "^3.1.0",
|
||||
"jest": "^26.6.3",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
|
|
Ładowanie…
Reference in New Issue