はじめに
youtubeで以下の動画を見かけました。
見たところWebページでも作れそうに思ったので、勉強がてら再現してみました。
なんとなく幾何学模様の描画と宣言的UIが相性良さそうな気がしたのでReactを使ってみました。
下準備
今回はSE再生も再現するため、use-sound
をインストールします。
npx create-react-app reproduce-geometric-patterns --template typescript
cd reproduce-geometric-patterns
npm i use-sound
再現
背景を描画
まずは背景の円2つと基準の線を描画します。
import Circle from './Circle';
function App() {
return (
<Circle></Circle>
);
}
export default App;
const MAX_R = 300;
const MIN_R = 50;
const Circle = () => {
return (
<div style={{ textAlign: 'center' }}>
<svg width={MAX_R * 2 + 20} height={MAX_R * 2 + 20} viewBox={`-10 -10 ${MAX_R * 2 + 20} ${MAX_R * 2 + 20}`}>
<circle r={MAX_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='black' />
<circle r={MIN_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='gray' />
<line x1={MAX_R} y1='0' x2={MAX_R} y2={MAX_R - MIN_R} stroke='gray' />
</svg>
</div>
)
}
export default Circle;
回る点を描画(まだ回らない)
次に、回って音を鳴らす点を描画します。
(Qiitaにreactのdiff表現が無い...)
まず1つ
<svg width={MAX_R * 2 + 20} height={MAX_R * 2 + 20} viewBox={`-10 -10 ${MAX_R * 2 + 20} ${MAX_R * 2 + 20}`}>
<circle r={MAX_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='black' />
<circle r={MIN_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='gray' />
<line x1={MAX_R} y1='0' x2={MAX_R} y2={MAX_R - MIN_R} stroke='gray' />
+ <circle r='5' cx={MAX_R} cy='0' fill='red' />
</svg>
動画だと18個あるので、mapで繰り返し表示します。
const MAX_R = 300;
const MIN_R = 50;
const Circle = () => {
return (
<div style={{ textAlign: 'center' }}>
<svg width={MAX_R * 2 + 20} height={MAX_R * 2 + 20} viewBox={`-10 -10 ${MAX_R * 2 + 20} ${MAX_R * 2 + 20}`}>
<circle r={MAX_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='black' />
<circle r={MIN_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='gray' />
<line x1={MAX_R} y1='0' x2={MAX_R} y2={MAX_R - MIN_R} stroke='gray' />
- <circle r='5' cx={MAX_R} cy='0' fill='red' />
+ {getRadiuses(18).map(r => <circle r='5' cx={MAX_R} cy={MAX_R - r} fill='red' />)}
</svg>
</div>
)
}
+ const getRadiuses = (pointNum: number): number[] => {
+ if (pointNum === 1) return [MAX_R];
+ if (pointNum === 2) return [MAX_R, MIN_R];
+ // 点が3つ以上の場合、最も外側の点と最も内側の点を結ぶ線をn - 1等分し、
+ // 分割点の数(n - 2)だけMIN_R + distanceすることで、各点の半径を取得
+ const middleLineNum = pointNum - 1;
+ const distance = (MAX_R - MIN_R) / middleLineNum;
+ const middlePointNum = middleLineNum - 1;
+ const rates = [...Array(middlePointNum)].map((_, i) => i + 1).reverse();
+ const middlePoints = rates.map(rate => MIN_R + distance * rate);
+ return [MAX_R, ...middlePoints, MIN_R];
+ }
きれいに点が18個並びました。
色はrgb変えるだけで簡単なので、今回はすべて赤のままでいきます。
点を回転させる(等速)
次に、すべての点を同じ角速度で回転させます。
angleはdegreeです。
x座標は初期位置を0°
とし、+MAX_R ~ -MAX_R
の間で変化させます。
y座標は初期位置を90°
としたいため、angle + 90
します。
+ import { useState, useEffect } from 'react';
const MAX_R = 300;
const MIN_R = 50;
const Circle = () => {
+ const [angle, setAngle] = useState(-1);
+ useEffect(() => {
+ const id = setInterval(() => {
+ setAngle(angle + 1);
+ }, 3);
+ return () => clearInterval(id);
+ }, [angle]);
+ const circlePositions = getCirclePositions(angle, getRadiuses(18));
return (
<div style={{ textAlign: 'center' }}>
<svg width={MAX_R * 2 + 20} height={MAX_R * 2 + 20} viewBox={`-10 -10 ${MAX_R * 2 + 20} ${MAX_R * 2 + 20}`}>
<circle r={MAX_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='black' />
<circle r={MIN_R} cx={MAX_R} cy={MAX_R} fill='transparent' stroke='gray' />
<line x1={MAX_R} y1='0' x2={MAX_R} y2={MAX_R - MIN_R} stroke='gray' />
- {getRadiuses(18).map(r => <circle r='5' cx={MAX_R} cy={MAX_R - r} fill='red' />)}
+ {circlePositions.map(point => <circle r='5' cx={point.x} cy={point.y} fill='red' />)}
</svg>
</div>
);
}
const getRadiuses = /* 省略 */
+ const getCirclePositions = (angle: number, radians: number[]): CirclePosition[] => {
+ return radians.map(radius => getPosition(angle, radius));
+ }
+ const getPosition = (angle: number, radius: number): CirclePosition => {
+ const xradian = Math.PI / 180 * angle;
+ const yradian = Math.PI / 180 * (angle + 90);
+ const xpos = radius - Math.sin(xradian) * radius + (MAX_R - radius);
+ const ypos = radius - Math.sin(yradian) * radius + (MAX_R - radius);
+ return {
+ x: xpos,
+ y: ypos
+ };
+ }
export default Circle;
+ type CirclePosition = {
+ x: number,
+ y: number
+ }
こんな感じで動きます。
(fps低いですが、本来はもっとなめらかに動いています)
点と点を線で結ぶ
隣り合う点の座標を線の始点・終点とします。
各点の座標はcirclePositions
に入っているので、reduce
で圧縮します。
/* 省略 */
const circlePositions = getCirclePositions(angle, getRadiuses(18));
+ const linePositions = getLinePositions(circlePositions);
/* 省略 */
{circlePositions.map((point, i) => <circle key={'circle' + i} r='5' cx={point.x} cy={point.y} fill='red' />)}
+ {linePositions.map((point, i) => <line key={'line' + i} x1={point.first.x} y1={point.first.y} x2={point.last.x} y2={point.last.y} stroke='black'></line>)}
/* 省略 */
+ const getLinePositions = (circlePositions: CirclePosition[]): LinePosition[] => {
+ return circlePositions.reduce((acc, cur, i) => {
+ if (i === 0) return [];
+ acc.push({
+ first: circlePositions[i - 1],
+ last: cur
+ });
+ return acc;
+ }, [] as LinePosition[]);
+ }
/* 省略 */
+ type LinePosition = {
+ first: CirclePosition,
+ last: CirclePosition
+ }
速度差をつけて点を回転させる
動画だと各点の角速度が異なっており、一番外側の点は回り始めてからすべての点が基準線で揃うまでに22周していました。
その1つ内側の点は21周、そのもう1つ内側の点は20周、...、一番内側の点は22 - 17 = 5周となっていました。
その周回数になるようにangleを割り、速度差をつけます。
/* 省略 */
const Circle = () => {
const [angle, setAngle] = useState(0);
+ const delays = [...Array(18).keys()].map((_, i) => (i + 5) / 22).reverse();
/* 省略 */
+ const circlePositions = getCirclePositions(angle, delays, getRadiuses(18));
const linePositions = getLinePositions(circlePositions);
/* 省略 */
- const getCirclePositions = (angle: number, radians: number[]): CirclePosition[] => {
+ const getCirclePositions = (angle: number, delays: number[], radians: number[]): CirclePosition[] => {
- return radians.map(radius => getPosition(angle, radius));
+ return radians.map((radius, i) => getPosition(angle / delays[i], radius));
}
/* 省略 */
描画部分はこれでほぼ再現完了です!
音を再生
動画では、点が基準線を通過した際に対応するSEを再生していました。
次に、mp3をインポートするためにsrc\@types
ディレクトリを作成し、中にaudio.d.ts
ファイルを作成します。
declare module '*.mp3';
次に音声ファイルをすべてインポートし、useSound
で準備します。
(全部同時に鳴る瞬間かなりうるさいので、volume: 0.2
で抑えています)
import { useState, useEffect } from 'react';
+ import useSound from 'use-sound';
+ import C4 from './sound_ド4.mp3';
+ import B3f from './sound_シ♭3.mp3';
... // 省略
const Circle = () => {
const [angle, setAngle] = useState(0);
const delays = [...Array(18).keys()].map((_, i) => 22 / (i + 5)).reverse();
+ const [playC4] = useSound(C4, { volume: 0.2 });
+ const [playB3f] = useSound(B3f, { volume: 0.2 });
+ const [playA3] = useSound(A3, { volume: 0.2 });
... // 省略
そして、angle / delay
が0に近い場合に音を鳴らします。
※一番外側の点に関してはdelays[0] == 1
のため% 360
で確実に0
になりますが、それ以外の場合は小数点になる場合があり% 360 === 0
が期待するタイミングでtrue
にならないことがあるため、% 360 < 1
としています。
if (angle / delays[0] % 360 === 0) playC4()
if (angle / delays[1] % 360 < 1) playB3f()
if (angle / delays[2] % 360 < 1) playA3()
これでSE再生部分も再現完了です!
完成品
GitHub Pagesで公開しましたので、ぜひ見てみてください!
https://uma0626.github.io/reproduce-geometric-patterns/
GitHubリポジトリは以下です。
おわりに
おもしろかった~