SVG를 활용한 word cloud 만들기

예리에르·2022년 2월 25일
3

Frontend

목록 보기
3/10
post-thumbnail

워드 클라우드란?

핵심 단어를 시각화하는 기법이다. 예를 들면 많이 언급될수록 단어를 크게 표현해 한눈에 들어올 수 있게 하는 기법입니다.

주로 워드클라우드는 라이브러리를 활용하여 쉽게 자신이 원하는 형태를 얻을 수 있습니다. 대표적인 라이브러리로는 reat-wordcloud가 있습니다.

하지만 기획과 디자인에 대응하고 커스텀을 위해 직접 자체 워드클라우드를 만들어 보았습니다.

개발은 4단계로 진행하였습니다.
1. 기본이 되는 100x100 사이즈의 Canvas를 기준으로 알고리즘을 구현한다.
2. 400x400 , 300x300 ... 등 다양한 크기의 Canvas에 그대로 옮긴다.
3. 실제로 서비스에 사용할때는 Canvas의 ctx가 아닌 HTML의 <span> 요소가 유용하므로 계산 값들을 옮겨준다.


4. 원하는 단색의 SVG를 같이 넣어주면 해당 SVG이미지에 맞게 단어도 배치할 수 있다.


여기서 굳이 HTML으로 옮기는 이유는 시각화 된 단어에 툴팁이나 Hover와 같은 이벤트와 효과를 적용하여 서비스 이용자에게 좋은 경험을 제공 할 수 있기 때문입니다.

0. 데이터 형태

let wordData = [{word: 'Apple', value: 20},
    {word: 'Banana', value: 10},
    {word: 'blueberry', value: 15},
    {word: 'pear', value: 8},
    {word: 'test', value: 18},
    {word: 'landy', value: 17},
    ...
];

워드 클라우드를 만들때 필수로 있어야 하는 데이터는

단어와 가중치 값

특히, 가중치는 단어의 폰트사이즈, 방향(가로/세로), 색깔 등 다양한 표현방식을 나타낼 수 있는 중요한 요소입니다.

1. 100x100 Canvas를 만들어서 글자를 배치해보자.

  • 글자가 겹치지 않게 배치되어야 하는건 기본 조건입니다. 큰 글자 사이에 빈공간을 찾은 후 작은 글자들을 배치하기 위해서는 높은 정확도가 필요합니다.
  • 위 기능을 구현할 때 처음부터 끝까지 모든 단어를 검색하는 완전탐색의 경우는 정확도를 높일 수 있겠지만 데이터가 많아지면 성능 저하가 마음에 걸려 다른 방법을 고민했습니다.

1-1. 알고리즘 때 많이 사용한 BFS, DFS를 사용해보자! (feat. 미로탐색)

  • 알고리즘을 공부한 분들이라면 BFS, DFS를 모르는 분들은 없을 것입니다. 2차원 배열의 미로를 탐색하는 과정이 비어있는 공간을 탐색하는 과정과 비슷하다고 생각했습니다.

BFS의 넓게 탐색하는 특징을 활용해 캔버스 전체를 빠르게 탐색해서 빈공간을 찾는다.
DFS의 적은 저장공간을 차지하면서 깊게 파악하는 특징을 활용해 글자가 들어갈 만한공간인지 확인한다.

즉,

  • BFS : 캔버스 전체에서 빈공간을 탐색
  • DFS : 찾은 위치에서 글자의 크기만큼 빈 공간이 있는지인지 탐색

1-2. BFS

  • 8방 탐색으로 Canvas의 빈자리를 확인했습니다.
  • 여기서 레이블 문 특징을 활용해 while에 label(qWhile)을 붙였습니다. 원래의 break는 가장 가까운 while, do-while, for, switch 를 종료하지만 레이블 문을 쓴다면 특정 레이블문을 종료합니다. 빈공간을 발견했다면 더이상 탐색하지않고 해당 while문을 종료합니다.
 private calculateXY = (canvasMap: any, w: WordType, canvasHeight: number, canvasWidth: number, visited: number[][], size1: number, size2: number) => {

        const moveXY = [{x: 0, y: -2}, {x: 2, y: -2}, {x: 2, y: 0}, {x: 2, y: 2}, {x: 0, y: 2}, {x: -2, y: 2}, {x: -2,y: 0}, {x: -2, y: -2}];
        let x = startPosX;
        let y = startPosY;
        const q = [];
        q.push({xPos: x, yPos: y});
        visited[y][x] = 1;
        let isFind = false;
        const checkCanvasMap = this.props.shape ? this.modeColor : 0;

        qWhile : while (q.length) {
            const {xPos = startPosX, yPos = startPosY} = q.shift() || {};
            for (let i = 0; i < 8; i++) {
                const nextY: number = yPos && yPos + moveXY[i].y;
                const nextX: number = xPos + moveXY[i].x;
                if (nextY >= 0 && nextY < canvasHeight && nextX >= 0 && nextX < canvasWidth) {
                    if (visited[nextY][nextX] === 0 && canvasMap[nextY][nextX] === checkCanvasMap) {
                        visited[nextY][nextX] = 1;
                        q.push({xPos: nextX, yPos: nextY});
                        const visitedChecker = this.visitedBuffer.map(v => v.slice());
                        if (this.checkPositionDFS(canvasMap, nextX, nextY, nextX, nextY, size1, size2, visitedChecker)) {
                            isFind = true;
                            x = nextX;
                            y = nextY
                            break qWhile;
                        }

                    }
                }
            }
        }

        return {x, y, isFind}
    }

1-3. DFS

  • 찾은 점에서 글자의 크기만큼 조금의 글자가 있는지 없는지 확인합니다. 만약에 한 칸이라도 다른 글자가 있는것 같으면 함수를 return 합니다.

    DFS로직을 작성 할 때는 적절한 제한이 중요합니다. 그렇지 않는다면 과도하게 한 곳으로 깊게 빠질 수 있기 때문입니다. 이러한 특징으로 코드의 current+1은 생각해 볼 필요가 있는 부분입니다.

다음 DFS를 시작할 때 이전 위치의 거리1의 다음 칸을 기준으로 잡고 시작합니다. 정확도는 높아질 수있겠지만 완전탐색와 비슷한 탐색을 하게됩니다. 다른 제한과 조건을 보안 후에 탐색 거리를 옵션으로 두는 개선된 방법을 생각중입니다. 😵‍💫

 private checkPositionDFS = (canvasMap: any, startX: number, startY: number, currentX: number, currentY: number, height: number, width: number, visitedChecker: number[][]) => {
        if (currentY < startY ||
            currentY >= startY + height ||
            currentX < startX ||
            currentX >= startX + width) {
            return true;
        }
        // 글자 존재 할 때 return
        if (this.props.shape) {
            if (canvasMap[currentY][currentX] < this.modeColor) {
                return false;
            }
        } else if (canvasMap[currentY][currentX] > 0) {
            return false
        }


        // 글자가 없고 방문하지 않았다면
        if (visitedChecker[currentY][currentX] === 0) {
            // 방문표시
            visitedChecker[currentY][currentX] = 1;

            // 다음 좌표를 재귀
            if (!this.checkPositionDFS(canvasMap, startX, startY, currentX + 1, currentY, height, width, visitedChecker) ||
                !this.checkPositionDFS(canvasMap, startX, startY, currentX, currentY + 1, height, width, visitedChecker)) {
                return false;
            }
        }
        return true;
    };

1-3. 이제 찾은 위치를 가지고 글자를 그려보자!

  • save() : canvas의 모든 상태를 저장합니다.
  • restore() : 가장 최근에 저장된 cavas 상태를 복원합니다.

캔버스를 그릴 때는 모양을 먼저 그리는 것이 아니라 그림을 그릴 캔버스를 변형한 후 그림을 그려야합니다. save() 가 호출 될 때 drawing 상태가 스택 위로 푸쉬 되는데, drawing 상태는 변형,속성, 현재의 clipping path로 이루어져 있습니다.

그래서 translate,rotate와 같은 함수를 활용해 캔버스를 자리 이동시킨 후 그리고 싶은 글자를 그리면 원하는 위치에 글자가 그려지게 됩니다.

 private drawWord = (canvas: any, ctx: CanvasRenderingContext2D, ctxW: CanvasRenderingContext2D, w: WordType, x: number, y: number, direction: string) => {
        if (direction === 'sero') {
            ctx.save();
            ctx.translate(x, y);
            ctx.rotate(Math.PI / 2);
            ctx.textBaseline = 'bottom';
            ctx.fillText(w.word, 0, 0);
            ctx.restore();

            return this.checkBitmap(canvas, ctx)
        } else {
            ctx.textBaseline = 'top';
            ctx.fillText(w.word, x, y);
            ctx.restore();

            return this.checkBitmap(canvas, ctx)
        }
    }

여기까지 이해하고 따라왔다면 데이터가 겹치지 않고 가로, 세로 잘 배치된 걸 볼수 있을 것입니다. 그런데 좀 이상 한점을 느끼셨나요? 내가 가지고 있는 데이터의 갯수는 20개인데 10개도 안되는 단어들이 배치되는 거를 볼 수 있을 것입니다. 🥲

결국 완전탐색을 통해 다시 빈곳을 확인해보는 과정이 필요하다!!

다시 돌고 돌아 하나하나 탐색하는 완전탐색으로 돌아왔습니다! ㅎㅎㅎ🥲🥲🥲
(웃읍시다! 하하하하)

1-4. 빈 곳을 찾아 빈틈없이 배치해보자!

(0,0) 부터 시작해서 끝까지 캔버스 전부를 확인하는데 DFS 조건을 통과하면 단어를 배치합니다.
break를 통해 더이상의 탐색은 멈추게됩니다.

private putWordGap = (canvas: any, ctx: CanvasRenderingContext2D,  w: WordType, Bitmap: number[][], idx: number, textHeight: number, textWidth: number) => {
        let direction;
        let x = 0;
        let y = 0;
        let isFind: boolean = false;

        if (idx % 2 !== 0) {
            const canvasWidth = this.originalCanvasHeight - textHeight;
            const canvasHeight = this.originalCanvasWidth - textWidth;
            rowFor:for (let row = 0; row < canvasHeight; row++) {
                colFor:for (let col = 0; col < canvasWidth; col++) {
                    const visitedChecker = this.visitedBuffer.map(v => v.slice());
                    const r = this.checkPositionDFS(Bitmap, col, row, col, row, textWidth, textHeight, visitedChecker);
                    if (r) {
                        Bitmap = this.drawWord(canvas, ctx, w, col, row, 'sero');
                        direction='sero';
                        x = col;
                        y = row;
                        isFind = true;
                        break rowFor;
                    }
                }
            }
        } else {
            const canvasWidth = this.originalCanvasWidth - textWidth;
            const canvasHeight = this.originalCanvasHeight - textHeight;
            rowFor:for (let row = 0; row < canvasHeight; row++) {
                colFor:for (let col = 0; col < canvasWidth; col++) {
                    const visitedChecker = this.visitedBuffer.map(v => v.slice());
                    const r = this.checkPositionDFS(Bitmap, col, row, col, row, textHeight, textWidth, visitedChecker);
                    if (r) {
                        Bitmap = this.drawWord(canvas, ctx, w, col, row, 'garo');
                        direction = 'garo';
                        x = col;
                        y = row;
                        isFind = true;
                        break rowFor;
                    }
                }
            }
        }
        return {Bitmap, x, y, direction, isFind};
    };

짜잔! 첫 워드클라우드를 완성했습니다. 이제 다음 스텝으로 고고~!

2. scale을 키워서 나타내자.

  • 위의 흐름을 잘 따라왔다면 scale키우는건 쉽습니다. 😃
    글자가 들어갈 위치를 다 알고 있기때문에 같은 비율로 글자크기와와 좌표를 키워주면 됩니다.
private drawWord = (canvas: any, ctx: CanvasRenderingContext2D, w: WordType, x: number, y: number, direction: string) => {
        if (direction === 'sero') {
         ...
            ctx.save();
            ctx.translate(x * this.scale, y * this.scale);
            ctx.rotate(Math.PI / 2);
            ctx.textBaseline = 'bottom';
            ctx.fillText(w.word, 0, 0);
            ctx.restore();

            return this.checkBitmap(canvas, ctx)
        } else {
          ...
            ctx.textBaseline = 'top';
            ctx.fillText(w.word, x * this.scale, y * this.scale);

            return this.checkBitmap(canvas, ctx)
        }
    }

3. Canvas를 HTML로 나타내보자.

3-1. 필요한 데이터를 담은 배열을 만들자.

 resultList.push({x, y, word: w.word, direction: 'sero', size: w.value});
  • 과정을 통해 얻은 글자의 데이터들을 넣어줍니다.(글자 좌표값, 글자 값, 방향, 크기 등)

3-2. 배열에 담긴 데이터를 absolute으로 <span>으로 그려준다.

resultList에 담긴 최종 단어들을 그려줍니다.

  • dominant-baseline : 상자의 텍스트와 인라인 컨테츠를 정렬하는데 사용되는 주요 기준선을 지정해 줍니다.

글씨를 회전할 때 텍스트의 밑부분을 기준으로 해야하기 때문에 회전이 필요한 세로 방향 글자에서 dominant-baseline을 text-after-edge 로 설정하였습니다.

<svg width={originalCanvasWidth * this.scale} height={originalCanvasHeight * this.scale}>
                    <g>
                        {this.resultList.map((d, i) => {
                            const x = d.x * this.scale;
                            const y = d.y * this.scale;
                            const fontsize = Math.max(d.size * this.scale, 10);

                                return <text key={`word-${i}`}
                                      style={{
                                          fontSize: fontsize,
                                          fontFamily: 'sans-serif',
                                          fill: this.textColor(d.size),
                                          whiteSpace: 'nowrap'
                                      }}
                                      dominantBaseline={'hanging'}
                                      transform={`translate(${x},${y}) ${d.direction === 'sero' ? `rotate(90,${0},${0})` : ''}`}
                                >{d.word}</text>
                        })}
                    </g>
                </svg>

비교 (Canvas vs HTML)


왼쪽이 Canvas를 통해 그린 워드클라우드고 오른쪽이 HTML로 옮긴 모습입니다. test라는 단어를 보면 오른쪽 워드클라우드가 조금 겹치는 모습을 볼 수있습니다.😵

100% 일치하지 않지만 꽤 높은 싱크로율이라고 생각합니다.

4. 원하는 모양의 워드 클라우드 형태가 있다면?

여기에 응용을 더해 하트, 네모, 세모, 고래 등 원하는 모양의 워드클라우드를 만들어 보겠습니다.

먼저 준비물로 배경과 모양이 명확하게 구분되는 단색의 SVG 아이콘 이미지입니다.

Canvas의 값을 이차원배열로

실제 너비와높이가 50인 이미지 경우, getImageDate()이 폭이 50픽셀이고 높이가 50픽셀인 개체를 만들어 ImageData는 모두 2,500픽셀이 됩니다. 배열 data은 각 픽셀에 대해 4개의 값을 저장하여 4 x 2,500 또는 모두 10,000개의 값을 만듭니다.

각 픽셀은 data배열 내에서 4개의 값으로 구성되므로 for루프는 4의 배수로 반복됩니다. 각 픽셀과 관련된 값은 순서대로 R(빨간색), G(녹색), B(파란색) 및 A(알파) 입니다. 값들을 더해 하나의 색깔 숫자로 구성된 이중 배열을 구성하였습니다.

// 비트맵 이차원 배열 return
    private checkBitmap = (canvas: any, ctx: CanvasRenderingContext2D) => {
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const canvasWidth = canvas.width;
        const canvasHeight = canvas.height;
        const canvasBitmap = Array.from(Array(canvasWidth), () => new Array(canvasHeight));
        for (let row = 0; row < originalCanvasHeight; row++) {
            for (let col = 0; col < originalCanvasWidth; col++) {
                const bitmapArr = Array.from(imageData.data.subarray(
                    (row * imageData.width + col) * 4,
                    (row * imageData.width + col) * 4 + 4
                ));
                const bitmapSum = bitmapArr.reduce((acc, curr) => acc + curr);
                this.getMode.hasOwnProperty(String(bitmapSum)) && bitmapSum !== 0 ? (this.getMode[String(bitmapSum)] += 1) : (this.getMode[String(bitmapSum)] = 1);
                canvasBitmap[row][col] = bitmapSum;

            }
        }
        const modeBitmapColor = Object.keys(this.getMode).sort((a, b) => this.getMode[b] - this.getMode[a])[0];
        this.modeColor = parseInt(modeBitmapColor);
        return canvasBitmap
    };

SVG 이미지에 따라 글자를 배치하기

this.getMode.hasOwnProperty(String(bitmapSum)) && bitmapSum !== 0 ? (this.getMode[String(bitmapSum)] += 1) : (this.getMode[String(bitmapSum)] = 1);
...
 const modeBitmapColor = Object.keys(this.getMode).sort((a, b) => this.getMode[b] - this.getMode[a])[0];
        this.modeColor = parseInt(modeBitmapColor);

가장 빈도가 높은 색깔의 숫자를 발견하는 로직을 추가하였습니다.
그리고 글자를 배치할 때 위치 의 색깔이 this.modeColor과 같다면 배치하면 됩니다.

마무리

지금까지 리액트로 canvas를 활용하여 워드 클라우드를 만들어 보았습니다. 개발 테스트 케이스로 영어를 사용하고 확인하다보니 실제 서비스에서 한글 워드 클라우드를 나타냈을때 약간의 아쉬움이 남았습니다.

하지만 생각하는 컴포넌트를 직접 만들며 겪는 시행착오는 누구에게도 얻을수 없는 좋은 경험입니다. 저희 솔루션팀은 시간이 걸리더라도 머리속 컴포넌트를 만들기 위해 활발한 의사소통과 열정을 가지고 있습니다. 저희 MI 개발실에 많은 관심 부탁드립니다.

profile
비전공 프론트엔드 개발자의 개발일기😈 ✍️

0개의 댓글