D3 | SVG from JS

공부의 기록·2022년 1월 10일
1

Dev 데이터 시각화

목록 보기
4/6
post-thumbnail

SVG

본 문서는 2022년 1월 10일 에 작성되었습니다.

앞서 말했듯,
SVG 는 확대하면 확대하는 만큼 픽셀 수가 늘어나서
이미지가 깨지지 않는 일명 확장 가능한 벡터 그래픽 입니다.


SVG from JS (혹은 Vanilla SVG)

여기서는 Vanilla SVG 를 만드는 방법에 대해서 다루고 있습니다.

  1. 나는 디자이너 및 프론트앤드 디자이너가 아닙니다.
    1.1. 나는 순수하게 SVG Design 을 공부하고자 하는 것이 아닙니다.

  2. 나는 D3 라이브러리를 사용할 것 입니다.
    2.1. 따라서 이를 사용하기 전 감을 잡기 위한 깊이까지만 학습할 생각입니다.

Sample

SVG 는 다음으로 구성되어 있습니다.

  1. SVG 컨테이너 (<svg>)
  2. SVG 구현체 (<circle> ... etc)
<svg width="500px" height="500px">
  <circle cx="100" cy="50" r="20" fill="darkmagenta" stroke="black" stroke-width="5"/>
<\svg>

SVG 구현체 속성 중
색상, 선의 두께 등의 간단한 요소는 CSS 파일 안에서 작성할 수도 있습니다.

.svg-settings {
   fill: darkmagenta;
   stroke: black;
   stroke-width: 5;
}

<svg width="500px" height="500px">
  <circle cx="100" cy="50" r="20" class="svg-settgins"/>
<\svg>

다양한 SVG Tags

SVG 는 원 뿐만 아니라 다양한 도형들을 출력할 수 있습니다.
현재 지원되고 있는 Tags 리스트는 다음과 같습니다.

  1. 직사각형 | <rect>
  2. 모서리 둥근 직사각형 | <rect>
  3. 원 | <circle>
  4. 타원 | <ellipse>
  5. 다각형 | <polygon>
  6. 선 | <line>
  7. 패스 | <path>
  8. 글자 | <text> </text>
  9. 그룹화 태그 | <g> </g>

위 중 Path 는 지금 당장 설명하기에는 너무나 방대하고 넓습니다.
여기서는 본 도서의 다음 인용문을 남기고 넘기도록 하겠다.

path 는 거의 하나의 새로운 언어라고 말할 수 있을 정도이다.
이를 이용하면 직선, 곡선 뿐만 아니라 거의 모든 SVG 도형을 만들 수 있다.

또한 글자의 경우 font-size, font-family, font-wieght, font-style 등 일반적인 글자 Tags 와 동일하게 사용하나, 글자 색 및 테두리는 fill 및 stroke 등을 사용한다.
이러한 글자 하나하나를 삐죽삐죽 하게 만드려면 text-anchor 등을 사용하는 법을 구글링 해보자.


다양한 SVG Attributes

# opacity

다음과 같은 HTML Tags 속성을 사용하여 투명도 설정이 가능하다.

  1. opacity
  2. fill-opacity
  3. stroke-opacity
  4. RGBA

# stroke

다음과 같은 HTML Tags 속성을 사용하여 stroke 를 수정할 수 있다.
단, opacity 속성은 위에서 언급하였으니 생략한다.

  1. stroke | 색상 설정
  2. stroke-width | 너비 설정
  3. stroke-linecap (butt(default),round,squre) | 끝 모양 설정
  4. stroke-dasharray | 마디 길이를 지정하며 점선으로 변경

다양한 SVG Transform

다음과 같은 CSS 속성을 사용하여 SVG 를 움직이거나 회전시킬 수 있다.

  1. translate(tx, [,ty])
  2. rotate(angle[,cx,cy])
  3. scale(sx,[,sy])

자세한 내용은 구글링을 해보자.


실습

앞으로 하는 모든 예제에서는 다음을 준수할 것입니다.

  1. JavaScript 의 객체 배열 타입의 데이터 존재
  2. index.html 에는 오직 <svg id="svg__container"> </svg>> 만 있다.

그래프를 그리는 코드는 모두 JavaScript 에서 진행할 것이며,
그 이유는 다음과 같다.

  1. 일일히 SVG Tags 를 작성하는 것은 전혀 쓸모가 없다.
  2. 일정한 Data 가 주어졌을 때 자동화된 SVG Tags 를 만드는 것을 연습해야 한다.

여기서는 다음의 예제를 해볼 것이다.

  1. 가로 막대기 그래프 예제
  2. 원 그래프 예제

# 가로 막대기 그래프

특정 집합군이 퍼센트 정보를 가지고 있는 데이터의 가로 막대기 그래프입니다.
모든 집합군의 퍼센트 총합은 당연히 100% 입니다.

일반적인 가로 막대기 그래프 에서는 다음 절차를 따르면 좋습니다. (Tutorials)

  1. 데이터 제작
  2. SVG Container 작성
  3. 막대기 그리기
  4. 막대기 가로 길이 맞추기(400px)
  5. 막대기 라벨 붙이기
  6. 막대기 축 만들기
  7. 막대기 축 라벨 붙이기
  8. 완성된 그래프를 뒤집기

0단계 | 데이터 제작

데이터 제작 단계를 통해 우리는 일정하게 정렬된 객체 배열을 얻게 됩니다.

1. Fake DB | DB 가 없을 때, 간단하게 객체 배열을 수기 작성한다. (다음을 복사해 사용하세요)
_2. Real DB | DB 에서 데이터를 받아와 객체 배열로 만든다.

const datas=[
    { range: "0-4세", value: "9.3%" },
    { range: "5-9세", value: "8.8%" },
    { range: "10-14세", value: "8.6%" },
    { range: "15-19세", value: "8.80%" },
    { range: "20-24세", value: "8.90%" },
    { range: "25-29세", value: "8.10%" },
    { range: "30-34세", value: "7.30%" },
    { range: "35-39세", value: "7.10%" },
    { range: "40-44세", value: "6.60%" },
    { range: "45-49세", value: "6.00%" },
    { range: "50-54세", value: "5.10%" },
    { range: "55-59세", value: "4.50%" },
    { range: "60-64세", value: "3.40%" },
    { range: "65-69세", value: "2.60%" },
    { range: "70-74세", value: "2.10%" },
    { range: "75-79세", value: "1.50%" },
    { range: "80세 이상", value: "1.60%" },
];

1단계 | SVG Container 만들기

Js 를 통해서 SVG 를 주입하기 위하여
SVG Container 를 index.html 에 작성해 둡니다.

<body>
  <svg id="svg__container" />
</body>

2단계 | 막대기 그리기

우리가 기억해야 할 핵심 사항(변동값)은 다음과 같습니다.

  1. 몇 개의 막대기 를 그릴 것인가?
  2. 개별 막대기의 길이 는 어떻게 정할 것인가?

그 외에 부수적인 사항(고정값)은 다음과 같습니다.

  1. 막대기 간의 간격은 어떻게 정할 것인가.
  2. 개별 막대기의 두께는 어떻게 정할 것인가.
const svgContainer=document.getElementById("svg__container");

let count=1;
const height=20; //고정값 * 1 = 고정값
const lineHeight=22; //고정값 * count = 변동값

let rect="";
datas.forEach(({range, value})=>{
    // 막대기의 높이는 22, 44, 66, 88 의 등비수열로 증가하며 높이는 20 이기 때문에,
    // 결과적으로 개별 두께는 20이며 간격은 2인 가로 막대기가 datas 의 길이만큼 생성됩니다.
    // 이때, 각 막대기의 길이는 datas.value 가 되는데, {range, value} 안에서 구조분해 할당을 이용했기에,
    // width="${value}" 를 주었습니다.
    // 구조분해할당이 싫다면 아래 추가 코드를 확인해주세요.
    rect+=`<rect x="0" y="${lineHeight*count}" height="${height}" width="${value}"/>`;
    count++;
});

svgContainer.innerHTML=rect;

구조분해 할당이 싫은 경우

// 여기서의 value 는 datas 객체 배열에서 배열을 부수고 남은 각 객체를 의미합니다.
/* 이러한 형태를 가지고 있을 것이다.
    value={
       range: "값",
       value: "값"
    };
*/
datas.forEach((value)=>{
   rect+=`<rect x="0" y="${lineHeight*count}" height="${height}" width="${value.value}"/>`;
   count++;
}

3단계 | 막대기 가로 길이 맞추기

2단계를 진행했을 때의 결과물은 매우 조잡합니다.
최대 길이가 약 9픽셀 정도로 편차 또한 매우 적어 가시성이 떨어집니다.

이에 최대 길이를 400px 로 고정하여 해당 비율(* 43.1) 만큼 모든 막대기를 늘려줄 것입니다.

이를 구현하는 방법은 2가지가 있습니다.

  1. 각각의 rect 에 값 곱하기
  2. rect 를 <g> 로 감싸고 scale 로 늘리기

## 3단계-1방법 | 일정비율값 계산하여 하나하나에 곱하기

// 위에 코드는 동일
const widthMultiply=43.1;

let rect="";

datas.forEach(({range, value})=>{
    rect+=`<rect x="0" y="${lineHeight*count}" 
              height="${height}"width="${value*widthMultiply}"/>`;
    count++;
});
svgContainer.innerHTML=rect;

## 3단계-2방법 | <g> 태그 이용하여 전체에 scale 곱하기

// 위에 코드는 동일
const widthMultiply=43.1;

let rect="";
rect+=`<g transform="scale(${widthMultiply},1)">`;

datas.forEach(({range, value})=>{
    rect+=`<rect x="0" y="${lineHeight*count}" 
            height="${height}"width="${value}"/>`;
    count++;
});
rect+="</g>";
svgContainer.innerHTML=rect;

4단계 | 막대기 라벨 붙이기

3단계를 진행하였을 때의 결과물은 가시성이 확보 되었습니다.
하지만 각 막대기가 어떤 값을 의미하는 지는 알 수 없습니다.

이에 datas 객체 배열의 각 객체인 datas[index].range 를 라벨로 붙여줄 것입니다.

/*
  3단계까지의 코드는 0행 부터 마지막 행까지 가로로 선을 긋는 것이었고
  4단계의 코드는 0행부터 마지막 행까지 가로선에 라벨을 붙이는 것이기에
    해당 코드의 적절한 부분에 코드를 덧붙이는 것으로 해결하였다.
*/

// let rect=""; 위의 코드는 완벽히 동일

let rect="";
let text="";

// rect 와 text 의 초기 위치 설정은 transform 을 통해서 했고
// 해당 수치는 아무 의미 없이 그냥 보기 좋게 배치 했을 뿐이다.
// 적절히 조절해보자

rect+=`<g transform="translate(100,30) scale(${widthMultiply},1)">`;
text=`<g transform="translate(0,45)">`;

datas.reverse().forEach(({range, value})=>{
    rect+=`<rect x="0" y="${lineHeight*count}" height="${height}"width="${value}"/>`;
    text+=`<text x="0" y="${lineHeight*count}">${range}</text>"`;
    
    count++;
});
rect+="</g>";
text+="</g>";

// svgContainer.innerHTML=rect; 를 아래로 수정
svgContainer.innerHTML=(rect+text);

5단계 | 막대기 축 만들기

4단계 진행 하면 이제 그럴듯한 막대기 그래프가 완성되어 간다.
하지만 엑셀 등의 그래프에 있는 막대기 축(값의 기준점 혹은 지표) 가 없다.

5단계에서는 막대기 축을 만들고
6단계에서는 막대기 축에 라벨을 붙여보자!!

/*
   4단계 까지는 데이터의 길이만큼 반복문을 돌렸으나,
   이제는 본인이 정한 숫자 만큼 축을 그릴 것이기 떄문에 별도로 코드를 작성하여
   4단계 코드 이후와 svgContainer.innerHTML="무언가" 이전의 사이 부분에 덧붙인다.
*/
const widthGap=2.5;

let lines="";

// 역시나 transform 을 이용하여 초기점을 잡아준다.
lines+=`<g transform="translate(100,30)" stroke="black">`;
count=0;
do {
    // widthAbsolute 는 말 그대로 0, 2.5 등의 데이터 값 자체를 의미한다.
    // widthRelative 는 막대기에 곱한 수인 43.1 을 곱해서 나온 값 0, 2.5*43.1 등 상대값을 의미한다.
    const widthAbsolute=widthGap*count;
    const widthRelative=widthAbsolute*widthMultiply;
  
    lines+=`<line x1="${widthRelative}" y1="15" x2="${widthRelative}" y2="5"/>`;
  
    count++;
    if (count>=5) break;
} while(true);
lines+="</g>";

// svgContainer.innerHTML=(rect+text); 를 아래로 수정
svgContainer.innerHTML=(rect+text+lines);

6단계 | 막대기 축 라벨 붙이기

5단계와 6단계의 로직은 완벽하게 동일하다.
단, 첫 번째 라벨은 0이고 여기에 퍼센트를 붙이기 싫어서 삼항 연산자를 통해 linesLabel 의 SVG Tags 시작조건 과 종료조건 사이에 제약을 가했다.

const widthGap=2.5;

let lines="";
let linesLable="";
lines+=`<g transform="translate(100,30)" stroke="black">`;
linesLable+=`<g transform="translate(100,30)" text-anchor="middle">`;
count=0;
do {
    const widthAbsolute=widthGap*count;
    const widthRelative=widthAbsolute*widthMultiply;

    lines+=`<line x1="${widthRelative}" y1="15" x2="${widthRelative}" y2="5"/>`;
    linesLable+=`<text x="${widthRelative}" y="0">
                    ${(widthAbsolute===0) ? widthAbsolute : widthAbsolute+"%"}
                 </text>`;

    count++;
    if (count>=5) break;
} while(true);
lines+="</g>";
linesLable+="</g>";

svgContainer.innerHTML=(rect+text+lines+linesLable);

7단계 | 완성된 그래프를 뒤집기

데이터 뒤집기는 너무 간단하다.
우리는 datas 배열에 forEach 함수를 이용하여 데이터 막대기를 그렸는데,
이를 반대로 그려내면 그만이다.

따라서 datas.reverse().forEach() 를 이용하면 된다.

// datas.forEach(({range,value})=>{/* 프로세스 */}); 를 아래로 수정
datas.reverse().forEach(({range,value})=>{
  /* 프로세스 */
});

최종 해설

완성 코드는 하단부를 참고하자.

0단계 부터 7단계 에 각 부분의 상세한 해설이 있기 때문에,
전체적인 로직에 대한 설명을 적는 부분을 마련했다.

위에서 가변값과 불변값에 대한 언급을 잠깐 했었다.
해당 부분이 의미하는 것은 다음과 같다.

  1. datas 배열의 각 객체를 가로로 그리게 된다면,
    이에 따라서 각 막대기의 y축 시작점이 가변값 이 된다.
  2. datas 배열의 각 객체를 세로로 그리게 된다면,
    이에 따라서 막대기의 x축 시작점이 가변값 이 된다.
  3. datas 배열의 확장 방향과 무관하게
    각 막대기가 균일한 간격으로 배치 시키기 위해서는
    각 막대기의 번호를 count 와 같은 변수에 저장하고
    막대기의 높이는 line(ex: 20px) 으로 주고 간격은 lineHeight(혹은 lineGap)(ex: 22px*count) 으로 주면
    결과적으로 2px 의 간격으로 20px 의 너비인 막대기 그래프가 datas 배열 길이만큼 그려진다.
  4. 이러한 현상 탓에 line 은 불변값 이라고 불렀고 lineHieght는 가변값(22, 44, 66 ...) 으로 불렀다.

또한 4개의 SVG Tags 를 그리는데, 갯수를 기준으로 분류하면 다음과 같았다.

  1. 막대기와 막대기 라벨
  2. 축과 축 라벨

따라서 2개의 반복문으로 막대기와 축을 구분하여 코드를 작성하였다.
물론 대소 관계에 대한 조건문을 통하여 이를 작성할 수도 있으나,
개인적으로 반복문 안에 조건문이 중첩될수록 가시성이 떨어진다고 생각하여,
별도의 반복문을 작성하게 되었다.

최종 코드

Github unchaptered // 22-01-3/src/001 svg from js/datas.js

const datas=[
    { range: "0-4세", value: 9.3 },
    { range: "5-9세", value: 8.8 },
    { range: "10-14세", value: 8.6 },
    { range: "15-19세", value: 8.80 },
    { range: "20-24세", value: 8.90 },
    { range: "25-29세", value: 8.10 },
    { range: "30-34세", value: 7.30 },
    { range: "35-39세", value: 7.10 },
    { range: "40-44세", value: 6.60 },
    { range: "45-49세", value: 6.00 },
    { range: "50-54세", value: 5.10 },
    { range: "55-59세", value: 4.50 },
    { range: "60-64세", value: 3.40 },
    { range: "65-69세", value: 2.60 },
    { range: "70-74세", value: 2.10 },
    { range: "75-79세", value: 1.50 },
    { range: "80세 이상", value: 1.60 },
];

const svgContainer=document.getElementById("svg__container");

let count=1;
const height=20;
const lineHeight=22;
const widthMultiply=43.1;

// 데이터 막대기 그리기
let rect="";
let text="";

rect+=`<g transform="translate(100,30) scale(${widthMultiply},1)">`;
text=`<g transform="translate(0,45)">`;

datas.reverse().forEach(({range, value})=>{
    rect+=`<rect x="0" y="${lineHeight*count}" height="${height}"width="${value}"/>`;
    text+=`<text x="0" y="${lineHeight*count}">${range}</text>"`;
    
    count++;
});
rect+="</g>";
text+="</g>";

//  축 만들기 (2.5 % 간격으로 10%까지 (0, 2.5%, 5%, 7,5% 10%))
const widthGap=2.5;

let lines="";
let linesLable="";
lines+=`<g transform="translate(100,30)" stroke="black">`;
linesLable+=`<g transform="translate(100,30)" text-anchor="middle">`;
count=0;
do {
    const widthAbsolute=widthGap*count;
    const widthRelative=widthAbsolute*widthMultiply;

    lines+=`<line x1="${widthRelative}" y1="15" x2="${widthRelative}" y2="5"/>`;
    linesLable+=`<text x="${widthRelative}" y="0">
                  ${(widthAbsolute===0) ? widthAbsolute : widthAbsolute+"%"}
                 </text>`;

    count++;
    if (count>=5) break;
} while(true);
lines+="</g>";
linesLable+="</g>";

svgContainer.innerHTML=(rect+text+lines+linesLable);

결과물

나아가기

이제 남은 것들은 다음과 같다.

  1. 각 라벨(x축, y축)의 의미
  2. 막대기 그래프 전체의 의미

그러나 이 부분은 굳이 SVG 로 만들지 않아도 된다고 생각하며,
크게 의미가 있다고 생각하지 않았기에 적지 않았다.

이 또한 SVG 안에 넣고 싶다면 txt 를 이용하되
굳이 반복문을 이용하지 않고 일일히 작성하여 svgContainer.innerHTML 안에 추가해보자.


후기

  1. SVG 어렵다.
  2. SVG Vanilla 는 그나마 나은거 같다.
    함수로 코드를 줄이니까 좀 살맛난다.
  3. 하지만 실무 적용 가능한 수준까지는 꽤 오래걸릴 것 같다 라는 생각이 든다.
profile
2022년 12월 9일 부터 노션 페이지에서 작성을 이어가고 있습니다.

0개의 댓글