I’ve been working on a back design for playing cards, and decided to make a tool to draw ornate spiral figures. To make things frustrating challenging, I’m using Reason / React to generate SVG.

I’m still learning how to organize React components in Reason React. I haven’t been able to figure out how to split up the sliders into a subcomponent yet, so the code quality is a bit disappointing.

I noticed some strange things along the way. The <input type="range"> slider has some interesting types, with min being typed as an int, and max being typed as a string.

I struggled with building an SVG path which wants an array of points as a long string. A method to join an array of strings isn’t present in the standard (non-Javascript) library. Initially, I converted the array(point) to a list and ran List.fold_left to flatten it down to a string. But in the end, I used Js.Array.joinWith(" ") which is probably faster.

SpirographGenerator.re

type action =
| SetAngleA(string)
| SetAngleB(string)
| SetAngleC(string)
| SetAngleD(string)
| SetScaleA(string)
| SetScaleB(string)
| SetScaleC(string)
| SetScaleD(string)
| SetOffset(string)
| SetRepeatCount(string)
| SetRepeatOffset(string)
| SetThickness(string)
| SetScale(string)
;


type state = {
	angleA: int,
	angleB: int,
	angleC: int,
	angleD: int,
	scaleA: int,
	scaleB: int,
	scaleC: int,
	scaleD: int,
	offset: int,
	repeatCount: int,
	repeatOffset: int,
	thickness: int,
	scale: int,
};

type point = {
	x: float,
	y: float
};

module SvgPath = {
	[@react.component]
	let make = (~state) => {
		let moveto(p) = Js.Float.toString(p.x) ++ "," ++ Js.Float.toString(p.y);

		let points(step) = {

			let count = 360 * 8;
		
			let offset = float_of_int(state.offset) /. 500.0 *. float_of_int(step);
			let repeatOffset = float_of_int(state.repeatOffset) /. 50.0 *. float_of_int(step);
			let angleA = float_of_int(state.angleA);
			let angleB = float_of_int(state.angleB);
			let angleC = float_of_int(state.angleC);
			let angleD = float_of_int(state.angleD);
			let scaleA = float_of_int(state.scaleA);
			let scaleB = float_of_int(state.scaleB);
			let scaleC = float_of_int(state.scaleC);
			let scaleD = float_of_int(state.scaleD);
			let scale = float_of_int(state.scale) /. 100.0;

			let points = Array.make(count + 1, {x: 0.0, y: 0.0})

			let radian_conv = 0.01745329251;

			for (i in 0 to count) {

				let degrees = float_of_int(i) /. float_of_int(count) *. 360.0;


				let x = sin(degrees *. radian_conv *. angleA) *. scaleA;
				let y = cos(degrees *. radian_conv *. angleA) *. scaleA;

				let xx = x +. sin(degrees *. radian_conv *. angleB +. offset) *. scaleB;
				let yy = y +. cos(degrees *. radian_conv *. angleB +. offset) *. scaleB;

				let xxx = xx +. sin(degrees *. radian_conv *. angleC) *. (scaleC);
				let yyy = yy +. cos(degrees *. radian_conv *. angleC) *. (scaleC);

				let xxxx = xxx +. sin(degrees *. radian_conv *. angleD) *. (scaleD +. repeatOffset);
				let yyyy = yyy +. cos(degrees *. radian_conv *. angleD) *. (scaleD +. repeatOffset);

				points[i] = {
					x: xxxx *. scale,
					y: yyyy *. scale
				}
			}

			let joined = points 
				|> Array.map((p) => moveto(p)) 
				|> Js.Array.joinWith(" ");

			"M" ++ joined ++ "z"
			
		};

		let count = state.repeatCount;
		let repeatCounter = Array.make(count + 1, 0);

		for (i in 0 to count) {
			repeatCounter[i] = i;
		};
		
		(ReasonReact.array(
			repeatCounter 
				|> Array.map(step => {
					let thickness = state.thickness;
					
					let lineThickness = (thickness > 0) ? Js.Float.toString(float_of_int(step) *. float_of_int(state.thickness) /. float_of_int(count)) : "1.0";

					<path 
						transform={"translate(" ++ string_of_int(640/2) ++ " " ++ string_of_int(640/2) ++ ")"}  
						d=points(step)
						stroke="black" 
						strokeWidth=lineThickness
						fill="none" 
					/>
				}
			) 
		))

		

	}
}

module SvgRender = {


	[@react.component]
	let make = (~state) => {

		<svg width="640" height="640">
			<rect 
				width="640" 
				height="640" 
				style=ReactDOMRe.Style.make(
					~fill="#fff", 
					~border="1px solid #ccc", 
					~padding="20px", ()) 
			/>
			
			<SvgPath state />

		</svg>
	}
}


[@react.component]
let make = () => {

	let (state, dispatch) =
		React.useReducer(
			(state, action) =>
				switch (action) {
					| SetAngleA(n) => {...state, angleA: int_of_string(n)}
					| SetAngleB(n) => {...state, angleB: int_of_string(n)}
					| SetAngleC(n) => {...state, angleC: int_of_string(n)}
					| SetAngleD(n) => {...state, angleD: int_of_string(n)}
					| SetScaleA(n) => {...state, scaleA: int_of_string(n)}
					| SetScaleB(n) => {...state, scaleB: int_of_string(n)}
					| SetScaleC(n) => {...state, scaleC: int_of_string(n)}
					| SetScaleD(n) => {...state, scaleD: int_of_string(n)}
					| SetOffset(n) => {...state, offset: int_of_string(n)}
					| SetRepeatOffset(n) => {...state, repeatOffset: int_of_string(n)}
					| SetRepeatCount(n) => {...state, repeatCount: int_of_string(n)}
					| SetThickness(n) => {...state, thickness: int_of_string(n)}
					| SetScale(n) => {...state, scale: int_of_string(n)}
				},
			{
				angleA: -8, 
				angleB: 17, 
				angleC: 7, 
				angleD: 0, 
				scaleA: 82, 
				scaleB: 41, 
				scaleC: 144, 
				scaleD: 0, 
				offset: 0,
				repeatOffset: 45,
				repeatCount: 5,
				thickness: 1,
				scale: 100,
			},
		);

	<div> 
		<form style=ReactDOMRe.Style.make(~position="absolute", ~left="20px", ~width="160px", ~margin="10px", ())>

			<div>
				<label htmlFor="angleA">{ReasonReact.string("Angle A")} 
					{React.string(": " ++ string_of_int(state.angleA))}</label><br />
				<input 
					id="angleA"
					type_="range"
					value=string_of_int(state.angleA)
					name="angleA"
					min=(-72)
					max="72" 
					onChange=(
						event => dispatch(SetAngleA(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="angleB">{ReasonReact.string("Angle B")} 
					{React.string(": " ++ string_of_int(state.angleB))}</label><br />
				<input 
					id="angleB"
					type_="range"
					value=string_of_int(state.angleB)
					name="angleB"
					min=(-72)
					max="72" 
					onChange=(
						event => dispatch(SetAngleB(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="angleC">{ReasonReact.string("Angle C")} 
					{React.string(": " ++ string_of_int(state.angleC))}</label><br />
				<input 
					id="angleC"
					type_="range"
					value=string_of_int(state.angleC)
					name="angleC"
					min=(-72)
					max="72" 
					onChange=(
						event => dispatch(SetAngleC(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>
			
			<div>
				<label htmlFor="angleD">{ReasonReact.string("Angle D")} 
					{React.string(": " ++ string_of_int(state.angleD))}</label><br />
				<input 
					id="angleD"
					type_="range"
					value=string_of_int(state.angleD)
					name="angleD"
					min=(-72)
					max="72" 
					onChange=(
						event => dispatch(SetAngleD(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="scaleA">{ReasonReact.string("Scale A")} 
					{React.string(": " ++ string_of_int(state.scaleA))}</label><br />
				<input 
					id="scaleA"
					type_="range"
					value=string_of_int(state.scaleA)
					name="scaleA"
					min=(-360)
					max="360" 
					onChange=(
						event => dispatch(SetScaleA(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="scaleB">{ReasonReact.string("Scale B")} 
					{React.string(": " ++ string_of_int(state.scaleB))}</label><br />
				<input 
					id="scaleB"
					type_="range"
					value=string_of_int(state.scaleB)
					name="scaleB"
					min=(-360)
					max="360" 
					onChange=(
						event => dispatch(SetScaleB(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="scaleC">{ReasonReact.string("Scale C")} 
					{React.string(": " ++ string_of_int(state.scaleC))}</label><br />
				<input 
					id="scaleC"
					type_="range"
					value=string_of_int(state.scaleC)
					name="scaleC"
					min=(-360)
					max="360" 
					onChange=(
						event => dispatch(SetScaleC(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="scaleD">{ReasonReact.string("Scale D")} 
					{React.string(": " ++ string_of_int(state.scaleD))}</label><br />
				<input 
					id="scaleD"
					type_="range"
					value=string_of_int(state.scaleD)
					name="scaleD"
					min=(-360)
					max="360" 
					onChange=(
						event => dispatch(SetScaleD(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="offset">{ReasonReact.string("Offset")} {React.string(": " ++ string_of_int(state.offset))}</label><br />
				<input 
					id="offset"
					type_="range"
					value=string_of_int(state.offset)
					name="offset"
					min=(-360)
					max="360" 
					onChange=(
						event => dispatch(SetOffset(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="offset">{ReasonReact.string("Repeat Offset")} {React.string(": " ++ string_of_int(state.repeatOffset))}</label><br />
				<input 
					id="repeatOffset"
					type_="range"
					value=string_of_int(state.repeatOffset)
					name="repeatOffset"
					min=(-720)
					max="720" 
					onChange=(
						event => dispatch(SetRepeatOffset(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>

			<div>
				<label htmlFor="offset">{ReasonReact.string("Repeat Count")} {React.string(": " ++ string_of_int(state.repeatCount))}</label><br />
				<input 
					id="repeatCount"
					type_="range"
					value=string_of_int(state.repeatCount)
					name="repeatCount"
					min=(0)
					max="50" 
					onChange=(
						event => dispatch(SetRepeatCount(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>
			
			<div>
				<label htmlFor="thickness">{ReasonReact.string("Line Thickness")} {React.string(": " ++ string_of_int(state.thickness))}</label><br />
				<input 
					id="thickness"
					type_="range"
					value=string_of_int(state.thickness)
					name="thickness"
					min=(0)
					max="10" 
					onChange=(
						event => dispatch(SetThickness(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>
			<div>
				<label htmlFor="scale">{ReasonReact.string("Scale")} {React.string(": " ++ string_of_int(state.scale))}</label><br />
				<input 
					id="scale"
					type_="range"
					value=string_of_int(state.scale)
					name="scale"
					min=(0)
					max="400" 
					onChange=(
						event => dispatch(SetScale(ReactEvent.Form.target(event)##value))
					)
					style=ReactDOMRe.Style.make(~width="100%", ()) 					
				/>
			</div>
			
		</form>

		
		<SvgRender state />

	</div>;
};