import React, { useContext, useEffect, useRef, useState } from "react"; import { SvgVocabulary } from "../svg-vocabulary"; import { createSvgSymbolContext, SvgSymbolContent, SvgSymbolContext, SvgSymbolData, } from "../svg-symbol"; import { AttachmentPointType, iterAttachmentPoints, PointWithNormal, } from "../specs"; import { getAttachmentTransforms } from "../attach"; import { scalePointXY } from "../point"; import { Point } from "../../vendor/bezier-js"; import { Random } from "../random"; import { SymbolContextWidget } from "../symbol-context-widget"; import { range } from "../util"; const DEFAULT_BG_COLOR = "#858585"; const SYMBOL_MAP = new Map( SvgVocabulary.map((symbol) => [symbol.name, symbol]) ); function getSymbol(name: string): SvgSymbolData { const symbol = SYMBOL_MAP.get(name); if (!symbol) { throw new Error(`Unable to find the symbol "${name}"!`); } return symbol; } function getAttachmentPoint( s: SvgSymbolData, type: AttachmentPointType, idx: number = 0 ): PointWithNormal { const { specs } = s; if (!specs) { throw new Error(`Symbol ${s.name} has no specs!`); } const points = specs[type]; if (!(points && points.length > idx)) { throw new Error( `Expected symbol ${s.name} to have at least ${ idx + 1 } ${type} attachment point(s)!` ); } return points[idx]; } function safeGetAttachmentPoint( s: SvgSymbolData, type: AttachmentPointType, idx: number = 0 ): PointWithNormal | null { try { return getAttachmentPoint(s, type, idx); } catch (e) { console.error(e); } return null; } type AttachmentChildren = JSX.Element | JSX.Element[]; type CreatureContextType = SvgSymbolContext & { attachmentScale: number; parent: SvgSymbolData | null; }; const DEFAULT_ATTACHMENT_SCALE = 0.5; const CreatureContext = React.createContext({ ...createSvgSymbolContext(), attachmentScale: DEFAULT_ATTACHMENT_SCALE, parent: null, }); type AttachmentIndices = { left?: boolean; right?: boolean; }; type CreatureSymbolProps = AttachmentIndices & { data: SvgSymbolData; children?: AttachmentChildren; attachTo?: AttachmentPointType; indices?: number[]; }; function getAttachmentIndices(ai: AttachmentIndices): number[] { const result: number[] = []; if (ai.left) { result.push(0); } if (ai.right) { result.push(1); } if (result.length === 0) { result.push(0); } return result; } const CreatureSymbol: React.FC = (props) => { const ctx = useContext(CreatureContext); const { data, attachTo } = props; const ourSymbol = ( <> {props.children && ( {props.children} )} ); if (!attachTo) { return ourSymbol; } const parent = ctx.parent; if (!parent) { throw new Error( `Cannot attach ${props.data.name} because it has no parent!` ); } const attachmentIndices = props.indices || getAttachmentIndices(props); const children: JSX.Element[] = []; for (let attachIndex of attachmentIndices) { const parentAp = safeGetAttachmentPoint(parent, attachTo, attachIndex); const ourAp = safeGetAttachmentPoint(data, "anchor"); if (!parentAp || !ourAp) { continue; } // If we're attaching something oriented towards the left, horizontally flip // the attachment image. let xFlip = parentAp.normal.x < 0 ? -1 : 1; // Er, things look weird if we don't inverse the flip logic for // the downward-facing attachments, like legs... if (parentAp.normal.y > 0) { xFlip *= -1; } const t = getAttachmentTransforms(parentAp, { point: ourAp.point, normal: scalePointXY(ourAp.normal, xFlip, 1), }); children.push( {ourSymbol} ); } return <>{children}; }; type AttachmentTransformProps = { transformOrigin: Point; translate: Point; scale: Point; rotate: number; children: JSX.Element; }; const AttachmentTransform: React.FC = (props) => ( {/** * We originally used "transform-origin" here but that's not currently * supported by Safari. Instead, we'll set the origin of our symbol to * the transform origin, do the transform, and then move our origin back to * the original origin, which is equivalent to setting "transform-origin". **/} {props.children} ); type CreatureSymbolWithDefaultProps = Omit & { data?: SvgSymbolData; }; function createCreatureSymbol( name: string ): React.FC { const data = getSymbol(name); return (props) => ; } const Eye = createCreatureSymbol("eye"); const Hand = createCreatureSymbol("hand"); const Arm = createCreatureSymbol("arm"); const Antler = createCreatureSymbol("antler"); const Crown = createCreatureSymbol("crown"); const Wing = createCreatureSymbol("wing"); const MuscleArm = createCreatureSymbol("muscle_arm"); const Leg = createCreatureSymbol("leg"); const Tail = createCreatureSymbol("tail"); function getSymbolWithAttachments( numAttachmentKinds: number, rng: Random ): JSX.Element { const children: JSX.Element[] = []; const root = rng.choice(SvgVocabulary); if (root.specs) { const attachmentKinds = rng.uniqueChoices( Array.from(iterAttachmentPoints(root.specs)) .filter((point) => point.type !== "anchor") .map((point) => point.type), numAttachmentKinds ); for (let kind of attachmentKinds) { const attachment = rng.choice(SvgVocabulary); const indices = range(root.specs[kind]?.length ?? 0); children.push( ); } } return ; } const EYE_CREATURE = ( ); function randomlyReplaceParts(rng: Random, creature: JSX.Element): JSX.Element { return React.cloneElement(creature, { data: rng.choice(SvgVocabulary), children: React.Children.map(creature.props.children, (child, i) => { return randomlyReplaceParts(rng, child); }), }); } type CreatureGenerator = (rng: Random) => JSX.Element; const COMPLEXITY_LEVEL_GENERATORS: CreatureGenerator[] = [ ...range(5).map((i) => getSymbolWithAttachments.bind(null, i)), (rng) => randomlyReplaceParts(rng, EYE_CREATURE), ]; const MAX_COMPLEXITY_LEVEL = COMPLEXITY_LEVEL_GENERATORS.length - 1; function getSvgMarkup(el: SVGSVGElement): string { return [ ``, "", '', el.outerHTML, ].join("\n"); } function exportSvg(filename: string, svgRef: React.RefObject) { const svgEl = svgRef.current; if (!svgEl) { alert("Oops, an error occurred! Please try again later."); return; } const dataURL = `data:image/svg+xml;utf8,${encodeURIComponent( getSvgMarkup(svgEl) )}`; const anchor = document.createElement("a"); anchor.href = dataURL; anchor.download = filename; document.body.append(anchor); anchor.click(); document.body.removeChild(anchor); } const AutoSizingSvg = React.forwardRef( ( props: { padding: number; bgColor?: string; children: JSX.Element | JSX.Element[]; }, ref: React.ForwardedRef ) => { const { bgColor, padding } = props; const [x, setX] = useState(0); const [y, setY] = useState(0); const [width, setWidth] = useState(1); const [height, setHeight] = useState(1); const gRef = useRef(null); 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); } }); return ( {bgColor && ( )} {props.children} ); } ); function getDownloadFilename(randomSeed: number | null) { let downloadBasename = "mystic-symbolic-creature"; if (randomSeed !== null) { downloadBasename += `-${randomSeed}`; } return `${downloadBasename}.svg`; } export const CreaturePage: React.FC<{}> = () => { const svgRef = useRef(null); const [bgColor, setBgColor] = useState(DEFAULT_BG_COLOR); const [randomSeed, setRandomSeed] = useState(null); const [symbolCtx, setSymbolCtx] = useState(createSvgSymbolContext()); const [complexity, setComplexity] = useState(MAX_COMPLEXITY_LEVEL); const defaultCtx = useContext(CreatureContext); const newRandomSeed = () => setRandomSeed(Date.now()); const ctx: CreatureContextType = { ...defaultCtx, ...symbolCtx, fill: symbolCtx.showSpecs ? "none" : symbolCtx.fill, }; const creature = randomSeed === null ? EYE_CREATURE : COMPLEXITY_LEVEL_GENERATORS[complexity](new Random(randomSeed)); const handleSvgExport = () => exportSvg(getDownloadFilename(randomSeed), svgRef); return ( <>

Creature!

setBgColor(e.target.value)} />{" "}

{ setComplexity(parseInt(e.target.value)); newRandomSeed(); }} />{" "} {complexity === MAX_COMPLEXITY_LEVEL ? "bonkers" : complexity}

{" "} {" "}

{creature} ); };