mysticsymbolic.github.io/lib/pages/mandala-page.tsx

411 wiersze
11 KiB
TypeScript
Czysty Zwykły widok Historia

import React, { useEffect, useRef, useState } from "react";
import { AutoSizingSvg } from "../auto-sizing-svg";
import { getBoundingBoxCenter } from "../bounding-box";
import { ExportWidget } from "../export-svg";
import { HoverDebugHelper } from "../hover-debug-helper";
import { NumericSlider } from "../numeric-slider";
import {
noFillIfShowingSpecs,
safeGetAttachmentPoint,
SvgSymbolContent,
SvgSymbolContext,
SvgSymbolData,
swapColors,
} from "../svg-symbol";
2021-03-27 12:29:42 +00:00
import { VocabularyWidget } from "../vocabulary-widget";
import {
svgRotate,
svgScale,
SvgTransform,
svgTranslate,
} from "../svg-transform";
2021-03-27 12:29:42 +00:00
import { SvgVocabulary } from "../svg-vocabulary";
2021-03-27 19:19:36 +00:00
import { NumericRange, range } from "../util";
import { Random } from "../random";
import { PointWithNormal } from "../specs";
import { getAttachmentTransforms } from "../attach";
import { Checkbox } from "../checkbox";
import {
CompositionContextWidget,
createSvgCompositionContext,
} from "../svg-composition-context";
import { Page } from "../page";
type ExtendedMandalaCircleParams = MandalaCircleParams & {
scaling: number;
rotation: number;
2021-03-28 20:22:36 +00:00
symbolScaling: number;
symbolRotation: number;
animateSymbolRotation: boolean;
};
const CIRCLE_1_DEFAULTS: ExtendedMandalaCircleParams = {
data: SvgVocabulary.get("eye"),
radius: 300,
numSymbols: 5,
scaling: 1,
rotation: 0,
2021-03-28 20:22:36 +00:00
symbolScaling: 1,
symbolRotation: 0,
invertEveryOtherSymbol: false,
animateSymbolRotation: false,
};
const CIRCLE_2_DEFAULTS: ExtendedMandalaCircleParams = {
data: SvgVocabulary.get("leg"),
radius: 0,
numSymbols: 3,
scaling: 0.5,
rotation: 0,
2021-03-28 20:22:36 +00:00
symbolScaling: 1,
symbolRotation: 0,
invertEveryOtherSymbol: false,
animateSymbolRotation: false,
};
const RADIUS: NumericRange = {
min: -500,
max: 500,
step: 1,
};
const NUM_SYMBOLS: NumericRange = {
min: 1,
max: 20,
step: 1,
};
const SCALING: NumericRange = {
min: 0.1,
max: 1,
step: 0.05,
};
const ROTATION: NumericRange = {
min: 0,
max: 359,
step: 1,
};
/**
* Returns the anchor point of the given symbol; if it doesn't have
* an anchor point, return a reasonable default one by taking the
* center of the symbol and having the normal point along the negative
* y-axis (i.e., up).
*/
function getAnchorOrCenter(symbol: SvgSymbolData): PointWithNormal {
return (
safeGetAttachmentPoint(symbol, "anchor") || {
point: getBoundingBoxCenter(symbol.bbox),
normal: { x: 0, y: -1 },
}
);
}
type MandalaCircleParams = {
data: SvgSymbolData;
radius: number;
numSymbols: number;
2021-03-28 20:22:36 +00:00
symbolTransforms?: SvgTransform[];
invertEveryOtherSymbol: boolean;
};
2021-03-28 20:22:36 +00:00
type MandalaCircleProps = MandalaCircleParams & SvgSymbolContext;
function isEvenNumber(value: number) {
return value % 2 === 0;
}
2021-03-28 20:22:36 +00:00
const MandalaCircle: React.FC<MandalaCircleProps> = (props) => {
const degreesPerItem = 360 / props.numSymbols;
const { translation, rotation } = getAttachmentTransforms(
{
point: { x: 0, y: 0 },
normal: { x: 0, y: -1 },
},
getAnchorOrCenter(props.data)
);
const transform: SvgTransform[] = [
// Remember that transforms are applied in reverse order,
// so read the following from the end first!
// Finally, move the symbol out along the radius of the circle.
svgTranslate({ x: 0, y: -props.radius }),
// Then apply any individual symbol transformations.
...(props.symbolTransforms || []),
// First, re-orient the symbol so its anchor point is at
// the origin and facing the proper direction.
svgRotate(rotation),
svgTranslate(translation),
];
const invertEveryOtherSymbol =
isEvenNumber(props.numSymbols) && props.invertEveryOtherSymbol;
const symbols = range(props.numSymbols)
.reverse()
.map((i) => (
<SvgTransform
key={i}
transform={[svgRotate(degreesPerItem * i), ...transform]}
>
{invertEveryOtherSymbol && isEvenNumber(i) ? (
<SvgSymbolContent {...swapColors(props)} />
) : (
<SvgSymbolContent {...props} />
)}
</SvgTransform>
));
return <>{symbols}</>;
};
const ExtendedMandalaCircle: React.FC<
ExtendedMandalaCircleParams & SvgSymbolContext
2021-03-28 20:22:36 +00:00
> = ({ scaling, rotation, symbolScaling, symbolRotation, ...props }) => {
props = {
...props,
symbolTransforms: [svgScale(symbolScaling), svgRotate(symbolRotation)],
};
return (
<SvgTransform transform={[svgScale(scaling), svgRotate(rotation)]}>
<MandalaCircle {...props} />
</SvgTransform>
);
};
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;
onChange: (value: ExtendedMandalaCircleParams) => void;
}> = ({ idPrefix, value, onChange }) => {
return (
<div className="thingy">
<VocabularyWidget
id={`${idPrefix}symbol`}
label="Symbol"
value={value.data}
onChange={(data) => onChange({ ...value, data })}
choices={SvgVocabulary}
/>
<NumericSlider
id={`${idPrefix}radius`}
label="Radius"
value={value.radius}
onChange={(radius) => onChange({ ...value, radius })}
{...RADIUS}
/>
<NumericSlider
id={`${idPrefix}numSymbols`}
label="Number of symbols"
value={value.numSymbols}
onChange={(numSymbols) => onChange({ ...value, numSymbols })}
{...NUM_SYMBOLS}
/>
<NumericSlider
id={`${idPrefix}scaling`}
label="Scaling"
value={value.scaling}
onChange={(scaling) => onChange({ ...value, scaling })}
{...SCALING}
/>
<NumericSlider
id={`${idPrefix}rotation`}
label="Rotation"
value={value.rotation}
onChange={(rotation) => onChange({ ...value, rotation })}
{...ROTATION}
/>
2021-03-28 20:22:36 +00:00
<NumericSlider
id={`${idPrefix}symbolScaling`}
label="Symbol scaling"
value={value.symbolScaling}
onChange={(symbolScaling) => onChange({ ...value, symbolScaling })}
{...SCALING}
/>
<NumericSlider
id={`${idPrefix}symbolRotation`}
label="Symbol rotation"
disabled={value.animateSymbolRotation}
2021-03-28 20:22:36 +00:00
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}
onChange={(invertEveryOtherSymbol) =>
onChange({ ...value, invertEveryOtherSymbol })
}
/>
</div>
);
2021-03-27 19:19:36 +00:00
};
function getRandomCircleParams(rng: Random): MandalaCircleParams {
return {
data: rng.choice(SvgVocabulary.items),
radius: rng.inRange(RADIUS),
numSymbols: rng.inRange(NUM_SYMBOLS),
invertEveryOtherSymbol: rng.bool(),
};
}
2021-03-27 19:19:36 +00:00
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());
const [useTwoCircles, setUseTwoCircles] = useState(false);
const [invertCircle2, setInvertCircle2] = useState(true);
const [firstBehindSecond, setFirstBehindSecond] = useState(false);
2021-03-27 19:19:36 +00:00
const randomize = () => {
const rng = new Random(Date.now());
setCircle1({ ...circle1, ...getRandomCircleParams(rng) });
setCircle2({ ...circle2, ...getRandomCircleParams(rng) });
2021-03-27 19:19:36 +00:00
};
const isAnimated = isAnyMandalaCircleAnimated([circle1, circle2]);
const frameNumber = useAnimation(isAnimated);
const symbolCtx = noFillIfShowingSpecs(baseCompCtx);
const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx;
const circles = [
<ExtendedMandalaCircle
key="first"
{...animateMandalaCircleParams(circle1, frameNumber)}
{...symbolCtx}
/>,
];
if (useTwoCircles) {
circles.push(
<ExtendedMandalaCircle
key="second"
{...animateMandalaCircleParams(circle2, frameNumber)}
{...circle2SymbolCtx}
/>
);
if (firstBehindSecond) {
circles.reverse();
}
}
return (
<Page title="Mandala!">
<div className="sidebar">
<CompositionContextWidget ctx={baseCompCtx} onChange={setBaseCompCtx} />
<fieldset>
<legend>First circle</legend>
<ExtendedMandalaCircleParamsWidget
idPrefix="c1"
value={circle1}
onChange={setCircle1}
/>
</fieldset>
<div className="thingy">
<Checkbox
label="Add a second circle"
value={useTwoCircles}
onChange={setUseTwoCircles}
/>
</div>
{useTwoCircles && (
<fieldset>
<legend>Second circle</legend>
<ExtendedMandalaCircleParamsWidget
idPrefix="c2"
value={circle2}
onChange={setCircle2}
/>
<Checkbox
label="Invert colors"
value={invertCircle2}
onChange={setInvertCircle2}
/>{" "}
<Checkbox
label="Place behind first circle"
value={firstBehindSecond}
onChange={setFirstBehindSecond}
/>
</fieldset>
)}
<div className="thingy">
<button accessKey="r" onClick={randomize}>
<u>R</u>andomize!
</button>{" "}
<ExportWidget basename="mandala" svgRef={svgRef} />
</div>
2021-03-27 12:34:22 +00:00
</div>
<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>
</HoverDebugHelper>
</div>
</Page>
);
};