ようつべで見かけた幾何学模様動画を再現してみる

# はじめに
youtubeで以下の動画を見かけました。

https://youtube.com/shorts/DylJXy8tJfQ?si=VYuZIzVj0_0ON7Zq

見たところWebページでも作れそうに思ったので、勉強がてら再現してみました。

なんとなく幾何学模様の描画と宣言的UIが相性良さそうな気がしたのでReactを使ってみました。

# 下準備
今回はSE再生も再現するため、`use-sound`をインストールします。
```
npx create-react-app reproduce-geometric-patterns --template typescript
cd reproduce-geometric-patterns
npm i use-sound
```

# 再現

## 背景を描画
まずは背景の円2つと基準の線を描画します。
```react:App.tsx
import Circle from './Circle';

function App() {
  return (
    <Circle></Circle>
  );
}

export default App;
```

```react:Circle.tsx
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;
```
こんな感じになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177673/35520696-04c5-9324-2753-2ba629725663.png)

## 回る点を描画（まだ回らない）
次に、回って音を鳴らす点を描画します。
(Qiitaにreactのdiff表現が無い...)

まず1つ
```diff_typescript:Circle.tsx
      <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で繰り返し表示します。
```diff_typescript:Circle.tsx
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変えるだけで簡単なので、今回はすべて赤のままでいきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177673/4a9558e8-c011-2948-fcf4-38697875bea3.png)

## 点を回転させる（等速）
次に、すべての点を同じ角速度で回転させます。
angleはdegreeです。
x座標は初期位置を`0°`とし、`+MAX_R ~ -MAX_R`の間で変化させます。
y座標は初期位置を`90°`としたいため、`angle + 90`します。
```diff_typescript:Circle.tsx
+ 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低いですが、本来はもっとなめらかに動いています)
![capture.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177673/b05576b1-af2a-dd0f-96cf-163cc19df08f.gif)

## 点と点を線で結ぶ
隣り合う点の座標を線の始点・終点とします。
各点の座標は`circlePositions`に入っているので、`reduce`で圧縮します。
```diff_typescript:Circle.tsx
/* 省略 */
  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を割り、速度差をつけます。
```diff_typescript:Circle.tsx
/* 省略 */
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));
}
/* 省略 */
```
これで、動画のようなズレで点を移動させられました🥳
![capture3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177673/6bb1c3c3-fac6-b181-177d-8812aaf24cce.gif)

描画部分はこれでほぼ再現完了です！

## 音を再生
動画では、点が基準線を通過した際に対応するSEを再生していました。

まず、18個の音声ファイルを用意して配置します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177673/69d57fa9-713f-f824-6978-1378285fdbfa.png)

次に、mp3をインポートするために`src\@types`ディレクトリを作成し、中に`audio.d.ts`ファイルを作成します。
```typescript:audio.d.ts
declare module '*.mp3';
```

次に音声ファイルをすべてインポートし、`useSound`で準備します。
(全部同時に鳴る瞬間かなりうるさいので、`volume: 0.2`で抑えています)
```diff_typescript:Circle.tsx
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`としています。
```diff_typescript:Circle.tsx
  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リポジトリは以下です。

https://github.com/UMA0626/reproduce-geometric-patterns

# おわりに
おもしろかった～