<aside> <img src="/icons/bookmark_purple.svg" alt="/icons/bookmark_purple.svg" width="40px" />

목차

</aside>

이전 문서

워드 클라우드 UI 구현을 위한 방향성 고민

jQCloud 소스코드 분석

/*!
 * jQCloud Plugin for jQuery
 *
 * Version 1.0.4
 *
 * Copyright 2011, Luca Ongaro
 * Licensed under the MIT license.
 *
 * Date: 2013-05-09 18:54:22 +0200
 */

(function ($) {
  "use strict";
  $.fn.jQCloud = function (word_array, options) {
    // Reference to the container element
    var $this = this;
    // Namespace word ids to avoid collisions between multiple clouds
    // 워드 클라우드에 고유한 네임스페이스를 부여하여 충돌을 방지
    var cloud_namespace =
      $this.attr("id") || Math.floor(Math.random() * 1000000).toString(36);

    // Default options value
    var default_options = {
      width: $this.width(), // 컨테이너 너비
      height: $this.height(), // 컨테이너 높이
      center: {
        x: (options && options.width ? options.width : $this.width()) / 2.0, // 워드 클라우드 중심 x좌표
        y: (options && options.height ? options.height : $this.height()) / 2.0, // 워드 클라우드 중심 y좌표
      },
      delayedMode: word_array.length > 50, // 단어 개수가 많을 때 지연 렌더링 활성화
      shape: false, // 워드 클라우드 모양 (기본값: 타원형)
      encodeURI: true,
      removeOverflowing: true, // 컨테이너를 벗어나는 단어 제거
    };

    options = $.extend(default_options, options || {}); // 사용자 옵션과 기본 옵션을 합침

    // Add the "jqcloud" class to the container for easy CSS styling, set container width/height
    $this.addClass("jqcloud").width(options.width).height(options.height);

    // Container's CSS position cannot be 'static'
    if ($this.css("position") === "static") {
      $this.css("position", "relative");
    }

    var drawWordCloud = function () {
      // Helper function to test if an element overlaps others
      // elem: 새로 배치하려는 DOM 요소 (HTML Element).
      // other_elems: 이미 배치된 DOM 요소들의 배열.
      var hitTest = function (elem, other_elems) {
        // Pairwise overlap detection
        // 두 요소(a, b)가 서로 겹치는지 확인
        var overlapping = function (a, b) {
          if (
            // 두 요소의 중심 간 거리(|중심 X좌표 차이|)가 두 요소의 너비 합보다 작으면 가로로 겹치는 것으로 판단
            Math.abs(
              2.0 * a.offsetLeft +
                a.offsetWidth -
                2.0 * b.offsetLeft -
                b.offsetWidth
            ) <
            a.offsetWidth + b.offsetWidth
          ) {
            if (
              // 두 요소의 중심 간 거리(|중심 Y좌표 차이|)가 두 요소의 높이 합보다 작으면 세로로 겹치는 것으로 판단
              Math.abs(
                2.0 * a.offsetTop +
                  a.offsetHeight -
                  2.0 * b.offsetTop -
                  b.offsetHeight
              ) <
              a.offsetHeight + b.offsetHeight
            ) {
              return true;
            }
          }
          return false;
        };
        var i = 0;
        // Check elements for overlap one by one, stop and return false as soon as an overlap is found
        // other_elems 배열에 있는 요소들을 하나씩 순회하며 overlapping 함수를 호출하여 어느 한 요소와라도 겹치면 바로 true를 반환하고 반복을 중단.
        // 모든 요소를 순회했는데 겹치는 요소가 없으면 false를 반환.
        for (i = 0; i < other_elems.length; i++) {
          if (overlapping(elem, other_elems[i])) {
            return true;
          }
        }
        return false;
      };

      // Make sure every weight is a number before sorting
      for (var i = 0; i < word_array.length; i++) {
        word_array[i].weight = parseFloat(word_array[i].weight, 10);
      }

      // Sort word_array from the word with the highest weight to the one with the lowest
      // 단어 배열을 weight가 높은 순서대로 정렬
      word_array.sort(function (a, b) {
        if (a.weight < b.weight) {
          return 1;
        } else if (a.weight > b.weight) {
          return -1;
        } else {
          return 0;
        }
      });

      var step = options.shape === "rectangular" ? 18.0 : 2.0, // 단어를 배치할 때의 간격
        already_placed_words = [], // 이미 배치된 단어들의 배열
        aspect_ratio = options.width / options.height; // 컨테이너의 가로 세로 비율

      // Function to draw a word, by moving it in spiral until it finds a suitable empty place. This will be iterated on each word.
      // 각 단어를 <span> 태그로 생성하고, 적절한 클래스와 스타일을 적용하여 컨테이너에 추가
      // 단어를 스파이럴 모양이로 이동시켜보면서 빈 공간을 찾아 배치
      var drawOneWord = function (index, word) {
        // Define the ID attribute of the span that will wrap the word, and the associated jQuery selector string
        var word_id = cloud_namespace + "_word_" + index,
          word_selector = "#" + word_id,
          angle = 6.28 * Math.random(), // 단어 배치를 위한 회전 각도를 랜덤으로 설정 (0에서 2π(6.28) 사이의 라디안 단위로, 360도(원형 배치)를 랜덤하게 시작하도록 함)
          radius = 0.0, // 단어가 배치되는 위치의 초기 반지름 거리, 중심(0.0)에서 시작하여 바깥으로 이동
          steps_in_direction = 0.0, // Only used if option.shape == 'rectangular'
          quarter_turns = 0.0, // Only used if option.shape == 'rectangular'
          weight = 5, // 단어의 가중치를 5로 초기화 (가중치에 따라 단어의 크기(class="wX")가 결정됨)
          custom_class = "", // 사용자 정의 클래스명, 특정 단어에 별도의 스타일을 적용하거나 커스터마이징할 때 사용
          inner_html = "", // 단어의 텍스트 또는 링크 내용 저장
          word_span; // 단어를 표시하는 span 요소

        // Extend word html options with defaults
        word.html = $.extend(word.html, { id: word_id });

        // If custom class was specified, put them into a variable and remove it from html attrs, to avoid overwriting classes set by jQCloud
        // jQCloud가 생성하는 기본 클래스(wX 형식)가 덮어쓰기 되지 않도록, 사용자 정의 클래스와 분리(이후 word_span에 기본 클래스와 사용자 정의 클래스를 모두 추가)
        if (word.html && word.html["class"]) {
          custom_class = word.html["class"];
          delete word.html["class"];
        }

        // 단어의 중요도(weight)를 1에서 10까지의 정수 값으로 매핑하여 가중치에 따라 단어 크기 조정
        // word_array는 weight가 높은 순서대로 정렬되어 있으므로, 첫 번째 단어의 weight가 가장 크고 마지막 단어의 weight가 가장 작음
        if (word_array[0].weight > word_array[word_array.length - 1].weight) {
          // Linearly map the original weight to a discrete scale from 1 to 10
          weight =
            Math.round(
              ((word.weight - word_array[word_array.length - 1].weight) /
                (word_array[0].weight -
                  word_array[word_array.length - 1].weight)) *
                9.0
            ) + 1;
        }

        // span 요소 생성 및 클래스 추가
        word_span = $("<span>")
          .attr(word.html)
          .addClass("w" + weight + " " + custom_class);

        // Append link if word.url attribute was set
        if (word.link) {
          // If link is a string, then use it as the link href
          if (typeof word.link === "string") {
            word.link = { href: word.link };
          }

          // Extend link html options with defaults
          if (options.encodeURI) {
            word.link = $.extend(word.link, {
              href: encodeURI(word.link.href).replace(/'/g, "%27"),
            });
          }

          inner_html = $("<a>").attr(word.link).text(word.text);
        } else {
          inner_html = word.text;
        }
        word_span.append(inner_html);

        // Bind handlers to words
        if (!!word.handlers) {
          for (var prop in word.handlers) {
            if (
              word.handlers.hasOwnProperty(prop) &&
              typeof word.handlers[prop] === "function"
            ) {
              $(word_span).bind(prop, word.handlers[prop]);
            }
          }
        }

        // 컨테이너에 단어 추가
        $this.append(word_span);

        var width = word_span.width(),
          height = word_span.height(),
          // 단어를 컨테이너의 중심(options.center)에 배치하도록 초기좌표 설정
          left = options.center.x - width / 2.0,
          top = options.center.y - height / 2.0;

        // Save a reference to the style property, for better performance
        var word_style = word_span[0].style;
        word_style.position = "absolute";
        word_style.left = left + "px";
        word_style.top = top + "px";

        while (hitTest(word_span[0], already_placed_words)) {
          // option shape is 'rectangular' so move the word in a rectangular spiral
          if (options.shape === "rectangular") {
            steps_in_direction++;
            if (
              steps_in_direction * step >
              (1 + Math.floor(quarter_turns / 2.0)) *
                step *
                ((quarter_turns % 4) % 2 === 0 ? 1 : aspect_ratio)
            ) {
              steps_in_direction = 0.0;
              quarter_turns++;
            }
            switch (quarter_turns % 4) {
              case 1:
                left += step * aspect_ratio + Math.random() * 2.0;
                break;
              case 2:
                top -= step + Math.random() * 2.0;
                break;
              case 3:
                left -= step * aspect_ratio + Math.random() * 2.0;
                break;
              case 0:
                top += step + Math.random() * 2.0;
                break;
            }
          } else {
            // 기본 타원형 모양의 스파이럴

            // 중심으로부터의 거리(반경)를 점진적으로 증가시키면서 단어를 배치
            radius += step;
            angle += (index % 2 === 0 ? 1 : -1) * step;

            // 원형 경로를 따르는 단어의 X, Y 좌표를 계산
            // aspect_ratio를 곱해서 컨테이너의 가로 세로 비율을 반영
            left =
              options.center.x -
              width / 2.0 +
              radius * Math.cos(angle) * aspect_ratio;
            top = options.center.y + radius * Math.sin(angle) - height / 2.0;
          }
          word_style.left = left + "px";
          word_style.top = top + "px";
        }

        // Don't render word if part of it would be outside the container
        // 단어가 컨테이너의 경계를 벗어나는 경우 해당 단어를 제거(remove)하고 배치를 종료
        if (
          options.removeOverflowing &&
          (left < 0 ||
            top < 0 ||
            left + width > options.width ||
            top + height > options.height)
        ) {
          word_span.remove();
          return;
        }

        already_placed_words.push(word_span[0]);

        // Invoke callback if existing
        if ($.isFunction(word.afterWordRender)) {
          word.afterWordRender.call(word_span);
        }
      };

      var drawOneWordDelayed = function (index) {
        index = index || 0;
        if (!$this.is(":visible")) {
          // if not visible then do not attempt to draw
          setTimeout(function () {
            drawOneWordDelayed(index);
          }, 10);
          return;
        }
        if (index < word_array.length) {
          drawOneWord(index, word_array[index]);
          setTimeout(function () {
            drawOneWordDelayed(index + 1);
          }, 10);
        } else {
          if ($.isFunction(options.afterCloudRender)) {
            options.afterCloudRender.call($this);
          }
        }
      };

      // Iterate drawOneWord on every word. The way the iteration is done depends on the drawing mode (delayedMode is true or false)
      if (options.delayedMode) {
        drawOneWordDelayed();
      } else {
        $.each(word_array, drawOneWord);
        if ($.isFunction(options.afterCloudRender)) {
          options.afterCloudRender.call($this);
        }
      }
    };

    // Delay execution so that the browser can render the page before the computatively intensive word cloud drawing
    setTimeout(function () {
      drawWordCloud();
    }, 10);
    return $this;
  };
})(jQuery);

간단히 정리하자면…

  1. 워드 클라우드를 생성할 키워드 목록을 가중치 내림차순으로 정렬한다.
  2. 정렬된 키워드 목록의 첫 번째 키워드부터 순서대로, 컨테이너의 중앙부터 바깥쪽으로 스파이럴 형태로 랜덤하게 설정되는 좌표에 배치해본 다음, hitTest 함수를 사용하여 다른 키워드 엘리먼트와 겹치는지 확인한다. 겹치지 않을 때까지 계속 새 좌표에 배치해본다.
  3. 배치된 키워드 엘리먼트를 already_placed_words 배열에 저장한다.

학습: CSS position

태그들의 위치를 결정하는 속성으로, 디폴트 포지션 값은 static 이다.

position: static

차례대로 왼쪽에서 오른쪽, 위에서 아래로 쌓인다

position: relative

position: absolute

position: fixed