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;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar .disabled {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar label.checkbox {
|
.sidebar label.checkbox {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
|
@ -1,33 +1,60 @@
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
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
|
* 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(
|
export const AutoSizingSvg = React.forwardRef(
|
||||||
(
|
(props: AutoSizingSvgProps, ref: React.ForwardedRef<SVGSVGElement>) => {
|
||||||
props: {
|
const { bgColor, padding, sizeToElement } = props;
|
||||||
padding: number;
|
|
||||||
bgColor?: string;
|
|
||||||
children: JSX.Element | JSX.Element[];
|
|
||||||
},
|
|
||||||
ref: React.ForwardedRef<SVGSVGElement>
|
|
||||||
) => {
|
|
||||||
const { bgColor, padding } = props;
|
|
||||||
const [x, setX] = useState(0);
|
const [x, setX] = useState(0);
|
||||||
const [y, setY] = useState(0);
|
const [y, setY] = useState(0);
|
||||||
const [width, setWidth] = useState(1);
|
const [width, setWidth] = useState(1);
|
||||||
const [height, setHeight] = useState(1);
|
const [height, setHeight] = useState(1);
|
||||||
const gRef = useRef<SVGGElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const svgEl = gRef.current;
|
if (!resizeToElement()) {
|
||||||
if (svgEl) {
|
const svgEl = gRef.current;
|
||||||
const bbox = svgEl.getBBox();
|
if (svgEl) {
|
||||||
setX(bbox.x - padding);
|
const bbox = svgEl.getBBox();
|
||||||
setY(bbox.y - padding);
|
setX(bbox.x - padding);
|
||||||
setWidth(bbox.width + padding * 2);
|
setY(bbox.y - padding);
|
||||||
setHeight(bbox.height + padding * 2);
|
setWidth(bbox.width + padding * 2);
|
||||||
|
setHeight(bbox.height + padding * 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import classNames from "classnames";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { float, NumericRange, slugify } from "./util";
|
import { float, NumericRange, slugify } from "./util";
|
||||||
|
|
||||||
|
@ -7,13 +8,18 @@ export type NumericSliderProps = NumericRange & {
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
value: number;
|
value: number;
|
||||||
valueSuffix?: string;
|
valueSuffix?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NumericSlider: React.FC<NumericSliderProps> = (props) => {
|
export const NumericSlider: React.FC<NumericSliderProps> = (props) => {
|
||||||
const id = props.id || slugify(props.label);
|
const id = props.id || slugify(props.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="thingy numeric-slider">
|
<div
|
||||||
|
className={classNames("thingy", "numeric-slider", {
|
||||||
|
disabled: props.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<label htmlFor={id}>{props.label}: </label>
|
<label htmlFor={id}>{props.label}: </label>
|
||||||
<span className="slider">
|
<span className="slider">
|
||||||
<input
|
<input
|
||||||
|
@ -23,6 +29,7 @@ export const NumericSlider: React.FC<NumericSliderProps> = (props) => {
|
||||||
max={props.max}
|
max={props.max}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
step={props.step}
|
step={props.step}
|
||||||
|
disabled={props.disabled}
|
||||||
onChange={(e) => props.onChange(float(e.target.value))}
|
onChange={(e) => props.onChange(float(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<span>
|
<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 { AutoSizingSvg } from "../auto-sizing-svg";
|
||||||
import { getBoundingBoxCenter } from "../bounding-box";
|
import { getBoundingBoxCenter } from "../bounding-box";
|
||||||
import { ExportWidget } from "../export-svg";
|
import { ExportWidget } from "../export-svg";
|
||||||
|
@ -36,6 +36,7 @@ type ExtendedMandalaCircleParams = MandalaCircleParams & {
|
||||||
rotation: number;
|
rotation: number;
|
||||||
symbolScaling: number;
|
symbolScaling: number;
|
||||||
symbolRotation: number;
|
symbolRotation: number;
|
||||||
|
animateSymbolRotation: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CIRCLE_1_DEFAULTS: ExtendedMandalaCircleParams = {
|
const CIRCLE_1_DEFAULTS: ExtendedMandalaCircleParams = {
|
||||||
|
@ -47,6 +48,7 @@ const CIRCLE_1_DEFAULTS: ExtendedMandalaCircleParams = {
|
||||||
symbolScaling: 1,
|
symbolScaling: 1,
|
||||||
symbolRotation: 0,
|
symbolRotation: 0,
|
||||||
invertEveryOtherSymbol: false,
|
invertEveryOtherSymbol: false,
|
||||||
|
animateSymbolRotation: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CIRCLE_2_DEFAULTS: ExtendedMandalaCircleParams = {
|
const CIRCLE_2_DEFAULTS: ExtendedMandalaCircleParams = {
|
||||||
|
@ -58,6 +60,7 @@ const CIRCLE_2_DEFAULTS: ExtendedMandalaCircleParams = {
|
||||||
symbolScaling: 1,
|
symbolScaling: 1,
|
||||||
symbolRotation: 0,
|
symbolRotation: 0,
|
||||||
invertEveryOtherSymbol: false,
|
invertEveryOtherSymbol: false,
|
||||||
|
animateSymbolRotation: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RADIUS: NumericRange = {
|
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<{
|
const ExtendedMandalaCircleParamsWidget: React.FC<{
|
||||||
idPrefix: string;
|
idPrefix: string;
|
||||||
value: ExtendedMandalaCircleParams;
|
value: ExtendedMandalaCircleParams;
|
||||||
|
@ -226,10 +248,18 @@ const ExtendedMandalaCircleParamsWidget: React.FC<{
|
||||||
<NumericSlider
|
<NumericSlider
|
||||||
id={`${idPrefix}symbolRotation`}
|
id={`${idPrefix}symbolRotation`}
|
||||||
label="Symbol rotation"
|
label="Symbol rotation"
|
||||||
|
disabled={value.animateSymbolRotation}
|
||||||
value={value.symbolRotation}
|
value={value.symbolRotation}
|
||||||
onChange={(symbolRotation) => onChange({ ...value, symbolRotation })}
|
onChange={(symbolRotation) => onChange({ ...value, symbolRotation })}
|
||||||
{...ROTATION}
|
{...ROTATION}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Animate symbol rotation"
|
||||||
|
value={value.animateSymbolRotation}
|
||||||
|
onChange={(animateSymbolRotation) =>
|
||||||
|
onChange({ ...value, animateSymbolRotation })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Invert every other symbol (applies only to circles with an even number of symbols)"
|
label="Invert every other symbol (applies only to circles with an even number of symbols)"
|
||||||
value={value.invertEveryOtherSymbol}
|
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<{}> = () => {
|
export const MandalaPage: React.FC<{}> = () => {
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const [circle1, setCircle1] = useState(CIRCLE_1_DEFAULTS);
|
const [circle1, setCircle1] = useState(CIRCLE_1_DEFAULTS);
|
||||||
const [circle2, setCircle2] = useState(CIRCLE_2_DEFAULTS);
|
const [circle2, setCircle2] = useState(CIRCLE_2_DEFAULTS);
|
||||||
const [baseCompCtx, setBaseCompCtx] = useState(createSvgCompositionContext());
|
const [baseCompCtx, setBaseCompCtx] = useState(createSvgCompositionContext());
|
||||||
|
@ -263,18 +316,27 @@ export const MandalaPage: React.FC<{}> = () => {
|
||||||
setCircle1({ ...circle1, ...getRandomCircleParams(rng) });
|
setCircle1({ ...circle1, ...getRandomCircleParams(rng) });
|
||||||
setCircle2({ ...circle2, ...getRandomCircleParams(rng) });
|
setCircle2({ ...circle2, ...getRandomCircleParams(rng) });
|
||||||
};
|
};
|
||||||
|
const isAnimated = isAnyMandalaCircleAnimated([circle1, circle2]);
|
||||||
|
const frameNumber = useAnimation(isAnimated);
|
||||||
const symbolCtx = noFillIfShowingSpecs(baseCompCtx);
|
const symbolCtx = noFillIfShowingSpecs(baseCompCtx);
|
||||||
|
|
||||||
const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx;
|
const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx;
|
||||||
|
|
||||||
const circles = [
|
const circles = [
|
||||||
<ExtendedMandalaCircle key="first" {...circle1} {...symbolCtx} />,
|
<ExtendedMandalaCircle
|
||||||
|
key="first"
|
||||||
|
{...animateMandalaCircleParams(circle1, frameNumber)}
|
||||||
|
{...symbolCtx}
|
||||||
|
/>,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (useTwoCircles) {
|
if (useTwoCircles) {
|
||||||
circles.push(
|
circles.push(
|
||||||
<ExtendedMandalaCircle key="second" {...circle2} {...circle2SymbolCtx} />
|
<ExtendedMandalaCircle
|
||||||
|
key="second"
|
||||||
|
{...animateMandalaCircleParams(circle2, frameNumber)}
|
||||||
|
{...circle2SymbolCtx}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
if (firstBehindSecond) {
|
if (firstBehindSecond) {
|
||||||
circles.reverse();
|
circles.reverse();
|
||||||
|
@ -330,12 +392,14 @@ export const MandalaPage: React.FC<{}> = () => {
|
||||||
<div
|
<div
|
||||||
className="canvas"
|
className="canvas"
|
||||||
style={{ backgroundColor: baseCompCtx.background }}
|
style={{ backgroundColor: baseCompCtx.background }}
|
||||||
|
ref={canvasRef}
|
||||||
>
|
>
|
||||||
<HoverDebugHelper>
|
<HoverDebugHelper>
|
||||||
<AutoSizingSvg
|
<AutoSizingSvg
|
||||||
padding={20}
|
padding={20}
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
bgColor={baseCompCtx.background}
|
bgColor={baseCompCtx.background}
|
||||||
|
sizeToElement={canvasRef}
|
||||||
>
|
>
|
||||||
<SvgTransform transform={svgScale(0.5)}>{circles}</SvgTransform>
|
<SvgTransform transform={svgScale(0.5)}>{circles}</SvgTransform>
|
||||||
</AutoSizingSvg>
|
</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": {
|
"cli-cursor": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^17.0.0",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^26.6.3",
|
||||||
"cheerio": "^1.0.0-rc.5",
|
"cheerio": "^1.0.0-rc.5",
|
||||||
|
"classnames": "^2.3.1",
|
||||||
"gh-pages": "^3.1.0",
|
"gh-pages": "^3.1.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"parcel-bundler": "^1.12.4",
|
"parcel-bundler": "^1.12.4",
|
||||||
|
|
Ładowanie…
Reference in New Issue