1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-10-28

はじめに

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つと基準の線を描画します。

App.tsx
import Circle from './Circle';

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

export default App;
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

回る点を描画(まだ回らない)

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

まず1つ

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で繰り返し表示します。

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

点を回転させる(等速)

次に、すべての点を同じ角速度で回転させます。
angleはdegreeです。
x座標は初期位置をとし、+MAX_R ~ -MAX_Rの間で変化させます。
y座標は初期位置を90°としたいため、angle + 90します。

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

点と点を線で結ぶ

隣り合う点の座標を線の始点・終点とします。
各点の座標はcirclePositionsに入っているので、reduceで圧縮します。

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を割り、速度差をつけます。

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

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

音を再生

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

まず、18個の音声ファイルを用意して配置します。
image.png

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

audio.d.ts
declare module '*.mp3';

次に音声ファイルをすべてインポートし、useSoundで準備します。
(全部同時に鳴る瞬間かなりうるさいので、volume: 0.2で抑えています)

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としています。

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リポジトリは以下です。

おわりに

おもしろかった~

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?