[ Interactive Coding ] Scatter Letters (NPM TS library)

박재현 ( Jcurver )·2023년 2월 4일
0
post-thumbnail

Canvas만을 이용하여 문자열 흩날리기 인터렉션을 제작하여 옵션을 커스텀 가능한 타입스크립트 라이브러리로 배포하였습니다.

감사하게도 Weekly Downloads 984를 기록하였습니다 :)

🎥 Youtube Demo : https://www.youtube.com/watch?v=eVliGZLmwks&feature=youtu.be

Github Repo: https://github.com/Jcurver/scatter-letters

NPM Package: https://www.npmjs.com/package/scatter-letters

들어가며

Interactive Developer 김종민님의 유튜브 영상을 정말 감명깊게 보았습니다.
자바스크립트를 이용해 500줄에 불과한 코드로 아름다운 인터렉션을 구현해낼 수 있다는 것이 매력적으로 느껴졌습니다.

그래서 제 힘으로 interaction code를 구현해보기로 했습니다.

그러기 위해서 처음에는 김종민님의 영상을 2개정도 클론 코딩을 진행하였으며,
대략적으로 어떤 메커니즘으로 interaction code가 동작하는지 이해할 수 있었습니다.

김종민님께서 제작한 영상은 PIXI 라이브러리를 쓰는 버전과 오직 Canvas Web API 만을 이용한 버전이 있었습니다. 이외에도 Framer Motion, Three.js등 애니메이션을 위해서 정말 다양한 프레임워크들이 있다는 점도 알게 되었습니다.

그 중에 저는 Interactive Coding이 처음이기도 하고 제 학습에 도움이 되고자 기본 제공되는 Canvas Web Api만을 사용하여 코드를 작성해보기로 했습니다.

그래서 무엇을 만들 것인가

샌드아트처럼 그림 또는 글자가 마우스를 댈 때마다 흩날리는 느낌을 만들어보기로 했습니다.
다양한 그림을 코드상에 구현하기에는 어려움이 있어 알파벳에 interaction 효과를 넣어보기로 했습니다.

마우스가 지나갈 때 마다 알파벳이 모래처럼 흩날리고, 시간이 지나면 부드럽게 원 위치로 돌아오도록!

숲을 보기 전에 먼저 나무를 보자

알파벳이 휘날리기 위해서는 여러개의 점이 모여 알파벳을 구성해야 합니다.
그러기 위해서는 각각의 점들이 클래스로 구성되어있고, 마우스 이벤트에 대해 즉각적으로 반응해야합니다.

따라서 각 점들이 어떤 조건을 가져야하는지 고민하고 정리해보았습니다.

  1. 마우스포인터와의 거리가 x보다 가까워지는 순간 점이 튕겨나간다.
    (이때 튕겨나가는 방향은 튕겨나가는 순간의 마우스포인터 - 점 과 같은 연장선상에 위치한다)
  2. 튕겨나간 이후 돌아오는 속도는 원래 위치에 가까워질수록 차츰 줄어든다.
  3. 색상도 튕겨나간 순간 변해야하며, 원점에 가까워질수록 조금씩 원점에서의 색상으로 돌아온다.
  4. 이미 튕겨나간 상태에서 마우스가 또 지나가는 경우, 1번 로직을 통해 다시 밀려난다.

위 계산은 태블릿에서 진행하였습니다. 내용은 다음과 같습니다.

앗, (px/py) 분모 분자가 바뀌었네요 ㅎㅎ

상기 모델을 코드로 옮겨봅니다

위 내용을 코드로 표현하면 다음과 같습니다.(update function)

    const px = this.x - pointerX; // 점과 마우스포인터 간 거리의 x절편
    const py = this.y - pointerY; // 점과 마우스포인터 간 거리의 y절편
    const fixPX = this.fixX - this.x; // 초기 x좌표
    const fixPY = this.fixY - this.y; // 초기 y좌표
    this.farRatio =
      Math.sqrt(fixPX * fixPX + fixPY * fixPY) / mouseThickness > 1
        ? 1
        : Math.sqrt(fixPX * fixPX + fixPY * fixPY) / mouseThickness;

    const theta = Math.atan2(py, px);
    const dx = mouseThickness * Math.cos(theta) - px;
    const dy = mouseThickness * Math.sin(theta) - py;

    const backTick = 1 + comebackSpeed * 0.01;

    if (Math.sqrt(px * px + py * py) < mouseThickness + 1) {
      this.x += dx * sparkLevel;
      this.y += dy * sparkLevel;
    } else {
      this.x = (this.x - this.fixX) / backTick + this.fixX;
      this.y = (this.y - this.fixY) / backTick + this.fixY;
    }

추가적인 옵션까지 붙인 코드입니다.

backTick이 클수록 흩어진 점이 돌아오는 속도가 빨라집니다.
sparkLevel이 클수록 흩어지는 범위가 넓어집니다.
farRaito는 향후 색상이 변할 때 천천히 색상을 변화시키기 위해 얼마나 멀리 떨어졌는지 수치화한 값입니다.

색상을 천천히 변화시키며 점을 그려내는 코드는 다음과 같습니다.(draw function)

    if (this.size === 0 || !ctx) return; // 예외처리!

    ctx.beginPath(); // 그림 그리기를 시작합니다.

    const rgb = getRGB(color); // HEX to RGB 원래 점 색상

    const dotrgb = getRGB(flyingDotColor); // HEX to RGB 날아간 점 색상

    const dotRatioRGB = dotrgb.map(
      (v, i) => (v - rgb[i]) * this.farRatio + rgb[i]
    ); // 점이 돌아오는 구간에서의 점 색상 배열(RGB)

    if (this.isMove) {
      ctx.fillStyle = `rgb(${dotRatioRGB[0]},${dotRatioRGB[1]},${dotRatioRGB[2]})`;
    } else {
      ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
    } // 점이 움직이고 있는 경우 돌아온 거리에 비례해서 고정점 색으로 점점 변화시킵니다.

    this.update({
      pointerX,
      pointerY,
      mouseThickness,
      sparkLevel,
      comebackSpeed,
    }); // 상기 updateFunction에 데이터를 넘겨 생성자를 변화시킵니다.

    ctx.fillRect(left + this.x, top + this.y, this.size, this.size); // 점을 그립니다.

이외에도 알파벳을 위해 2차원 배열을 노가다로 직접 만들고 이어붙이는 작업, 자간 및 띄어쓰기 너비 커스텀 등 다양한 작업을 진행하여 해당 기능에서 제가 생각할 수 있는 가능한 모든 것을 커스텀할 수 있도록 구현하였습니다. 최종 코드는 여기에 있습니다

requestAnimationFrame의 작동 원리 이해하기

requestAnimationFrame은 Web Api입니다. 먼저 mdn docs의 설명을 보면 다음과 같습니다.

브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출하게 합니다. 이 메소드는 리페인트 이전에 실행할 콜백을 인자로 받습니다.
화면에 새로운 애니메이션을 업데이트할 준비가 될때마다 이 메소드를 호출하는것이 좋습니다. 이는 브라우저가 다음 리페인트를 수행하기전에 호출된 애니메이션 함수를 요청합니다. 콜백의 수는 보통 1초에 60회지만, 일반적으로 대부분의 브라우저에서는 W3C 권장사항에 따라 그 수가 디스플레이 주사율과 일치하게됩니다.
대부분의 최신 브라우저에서는 성능과 배터리 수명 향상을 위해 requestAnimationFrame() 호출은 백그라운드 탭이나 hidden iframe 에서 실행이 중단됩니다.

이와 같은 이유로 setTimeout()과 같은 함수로 애니메이션을 구현할 때보다 많은 장점을 가지게 됩니다.
(더 많은 이유가 있고, 아래 내용에 포함되어 있습니다.)

사용방법은 함수 내에서 requestAnimationFrame(함수)형식으로 재귀적으로 호출해주어야합니다.

브라우저마다 내부적인 구현도 제각각 조금씩 다르고, 내용을 모두 담기엔 양이 많고 모르는 부분도 많습니다. 그 중 리페인트 관련된 내용은 이 블로그에 잘 작성되어있습니다.

저는 이벤트 루프 관점에서 requestAnimationFrame을 해석해보겠습니다.

이벤트루프에서 비동기 작업은 Callback Queue에 담겨서 Call Stack으로 이동하게 됩니다.
Callback Queue는 Microtask Queue, Macrotask Queue, Animation Frames 이렇게 3가지가 있습니다.

callstack으로 빠져나가는 우선순위는 Macrotask < AnimationFrames < MicroTask(가장 먼저)입니다.

requestAnimationFrame은 Animation Frames에 속하는 web api입니다.
즉 web api가 requestAnimationFrame을 받으면 Animation Frames Queue에 넘겨주게 됩니다.

각 Queue에 들어가게 되는 비동기 함수들에 대해서 간단히 정리해보면 다음과 같습니다.

Macrotask Queue : setTimeout, setInterval, setImmediate
Microtask Queue : Promise, async/await, process.nextTick, MutationObserver
Animation Frames : requestAnimationFrame

이벤트 루프는 Microtask Queue나 Animation Frames를 방문할 때는, 큐 안에 있는 모든 작업들을 수행하지만,
Task Queue를 방문할 때는 한 번에 하나의 작업만 call stack으로 전달하고 다른 Queue를 순회합니다.

위와 같은 이유로 requestAnimationFrame은 다른 비동기 api와 함께 실행되어도 성능을 보장받고 큰 문제없이 부드럽게 동작할 수 있습니다.

주의할 점은 animation이 진행되는 동안 animate 함수 내부에서 console.log 등을 사용해서는 안됩니다.
매 tick 마다 Call Stack에 계속 한 개 이상의 stack이 차고 비워지며 성능이 저하됩니다.
(실제로 내부 로직을 테스트하면서 콘솔을 찍어보니 매우 느려지고 버벅거림을 확인하였습니다.)

현 프로젝트와 같은 고성능의 animation이 요구되는 경우 Call Stack을 잘 관리해주어야 합니다.

Typescript Library로 NPM 배포하기

기존에 제작해 둔 Typescript Library Template Repository를 활용하도록 하겠습니다.

먼저 package.json 부터 살펴보겠습니다.

{
  "name": "scatter-letters",
  "version": "0.0.16",
  "packageManager": "yarn@3.2.4", 
  "exports": {
    ".": {
      "require": "./lib/index.js",
      "import": "./lib/index.mjs"
    }
  },
  "main": "./lib/index.js",
  "module": "./lib/index.mjs",
  "types": "./lib/index.d.ts",
  "files": [
    "lib",
    "src"
  ],
  "devDependencies": {
    "@swc-node/register": "^1.5.4",
    "@swc/core": "^1.3.11",
    "@swc/jest": "^0.2.23",
    "@types/jest": "^29.2.0",
    "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.15",
    "concurrently": "^7.5.0",
    "esbuild": "^0.15.12",
    "jest": "^29.2.2",
    "typescript": "^4.8.4"
  },
  "scripts": {
    "debug": "node -r @swc-node/register src/index.ts",
    "test": "jest",
    "test:cov": "jest --coverage",
    "test:watch": "jest --watch",
    "build": "yarn build:js && yarn build:dts",
    "build:dts": "tsc -p ./tsconfig.build.json --emitDeclarationOnly --noEmit false",
    "build:js": "node ./esbuild.config.js",
    "dev": "concurrently \"yarn build:js --watch\" \"yarn build:dts --watch\""
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/ifizzyou/scatter-letters"
  }
}

emitDeclarationOnly : .js파일을 만들지 않고 .d.ts파일만 생성합니다.
noEmit : 컴파일러가 JavaScript 코드, source map, declaration를 만들지 않도록 합니다. Babel이나 swc 같은 다른 도구가 TypeScript 파일을 JavaScript 환경 내에서 실행할 수 있는 파일로 변환하는 것을 처리할 공간을 만듭니다.
concurrently : 서버와 클라이언트를 한개의 스크립트로 실행하도록 해줍니다.

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module":"ESNext",
    "outDir": "lib",
    "declaration": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

declaration: d.ts 생성 여부
esModuleInterop : 모든 imports에 대한 namespace 생성을 통해 CommonJS와 ES Modules 간의 상호 운용성이 생기게할 지 여부
forceConsistentCasingInFileNames : 같은 파일에 대한 일관되지 않은 참조를 허용하지 않을 지 여부
strict : 모든 엄격한 타입-체킹 옵션 활성화 여부
skipLibCheck : 정의 파일의 타입 확인을 건너 뛸 지 여부

esbuild.config.js

const { build } = require("esbuild");
const { pnpPlugin } = require("@yarnpkg/esbuild-plugin-pnp");
const pkg = require("./package.json");

const watch = process.argv.includes("--watch");
const external = Object.keys({
  ...pkg.dependencies,
  ...pkg.peerDependencies,
});

const config = {
  entryPoints: ["./src/index.ts"],
  outdir: "lib",
  target: "es2015",
  bundle: true,
  sourcemap: true,
  external,
  watch,
  minify: !watch,
  plugins: [pnpPlugin()],
};

Promise.all([
  build({
    ...config,
    format: "cjs",
  }),
  build({
    ...config,
    format: "esm",
    outExtension: {
      ".js": ".mjs",
    },
  }),
]);

sourcemap : 브라우저 개발 도구창에서 소스를 확인할 수 있도록 해줍니다.
pnpPlugin : esbuild 가 yarn berry 방식의 의존성 패키지 관리 방식을 이해하도록 해줍니다.

빌드시간 최적화를 위해 promise.all을 통해서 병렬적으로 cjs와 esm 모듈을 빌드합니다.

이후 빌드 및 배포를 완료했습니다.

마치며

Interaction 작업을 진행하며 hot reload를 통해 바로바로 형태를 확인해가며 작업할 수 있어 매력적으로 느껴졌습니다.
초기 멘탈모델만 머릿속에 잘 구상하고 그것을 코드로 풀어내는 아이디어를 내야해서 창의적인 문제를 풀어내는 느낌도 받았습니다.
또한 Interaction code는 성능과 비주얼을 깊이있게 고민해야하기에 프론트엔드의 꽃이라고 할 수 있을 것 같습니다.

Interaction을 구현해 내고 커스텀 조건을 작성하는데에는 생각보다 긴 시간이 걸리지 않았습니다.
유튜브 학습시간 및 빌드환경 작업을 제외하고 8시간정도 소요되었습니다.

다양한 프론트엔드 분야에 대해서 직접 코드로 작성해가며 배우고 느껴보니 시야가 넓어지는 것 같아서 보람차게 느껴집니다.
읽어주셔서 감사드립니다.

profile
FE developer / Courage is very important when it comes to anything.

0개의 댓글