회사에서 분기마다 FE팀 자체 세미나를 하고 있는데, 2023년 1분기 세미나는 d3.js를 이용해 데이터 시각화하기
로 주제를 정했다. 데이터를 유의미하게 시각화하는 것이 그 데이터를 전달하는 좋은 방법이라고 생각했고, 원하는 대로 시각화하는 것 자체가 재미있다고 느껴서 정하게 되었다.
D3의 기본 사용 방법에서 부터 실제로 데이터를 시각화 하는 방법까지 내용을 다뤘고, 마지막엔 실제로 사용중인 서비스에서의 데이터를 갖고 서비스에서 제공하는 차트와 동일하게 만드는 과정을 담았다. 물론 실제 서비스에서의 차트는 클릭, 드래그, 확대/축소, 다양한 기능과 추세선 들이 가능했지만, 그런 부분들을 제외하고 보여지는 부분만을 다뤘다.
모든 발표가 끝난 후 팀원분들의 투표로 우수 발표자 순위를 정했는데 감사하게도 만장일치 투표 수 1위를 받았다😀 사내 세미나 덕분에 다른 팀원분들이 준비한 다양한 좋은 세미나도 들을 수 있었고, 스스로도 자료를 준비하면서 D3에 대해 조금 알게된 것 같아서 좋았다.
총 9명 중 자신의 표를 제외한 8표를 받아 1위😆
아래는 준비한 자료이다.
D3는 Selection 객체를 사용하고, select 메서드를 통해 querySelector 처럼 DOM 요소를 Selection 객체로 반환받아 D3 메서드를 통해 데이터를 시각화할 수 있다.
// index.html
<div id="chart-area"></div>
// main.js
const svg = d3
.select("#chart-area")
.append("svg")
.attr("width", 400)
.attr("height", 400);
svg
.append("circle")
.attr("cx", 200)
.attr("cy", 200)
.attr("r", 100)
.attr("fill", "blue");
Selection.data().join()
메서드를 통해 셀렉션 객체에 데이터를 바인딩하고, 데이터 조인할 수 있다.
const data = [25, 20, 10, 12, 15];
const svg = d3
.select("#chart-area")
.append("svg")
.attr("width", 400)
.attr("height", 400);
const circles = svg.selectAll("circle");
circles
.data(data)
.join("circle")
.attr("cx", (_, i) => 50 * i + 50)
.attr("cy", 250)
.attr("r", (d) => d)
.attr("fill", "red");
d3.scaleLinear()
, d3.scaleBand()
메서드를 통해 척도를 생성할 수 있다.(그 외 Log Scale, Time Scale, Ordinal Scale 등의 척도도 생성 가능하다.)
// buildings.json
[
{
name: "Burj Khalifa",
height: "350",
},
{
name: "Shanghai Tower",
height: "263.34",
},
{
name: "Abraj Al-Bait Clock Tower",
height: "254.04",
},
{
name: "Ping An Finance Centre",
height: "253.20",
},
{
name: "Lotte World Tower",
height: "230.16",
},
{
name: "Testing Tower",
height: "200.56",
},
{
name: "Testing Centre",
height: "181.24",
},
];
async function makeChart() {
const data = await d3.json("data/buildings.json");
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.height)])
.range([0, 300]);
const x = d3
.scaleBand()
.domain(data.map((d) => d.name))
.range([0, 200])
.paddingInner(0.3)
.paddingOuter(0.2);
// ...
svg
.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (d) => x(d.name))
.attr("y", 0)
.attr("width", x.bandwidth)
.attr("height", (d) => y(d.height))
.attr("fill", "lightblue");
}
makeChart();
d3.axisTop()
, axisBottom()
, axisLeft()
, axisRight()
메서드를 통해 d3에서 제공하는 축을 간단하게 만들 수 있다. 각 메서드들은 인자로 x, y 축 척도를 넘겨준다.
const x = d3
.scaleBand()
.domain(data.map((d) => d.name))
.range([0, WIDTH]);
// X Axis
const xAxis = d3.axisBottom(x);
g.append("g").call(xAxis);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.height)])
.range([HEIGHT, 0]);
// Y Axis
const yAxis = d3
.axisLeft(y)
.ticks(3)
.tickFormat((d) => d + "m");
g.append("g").call(yAxis);
D3의 기본을 알고 있으면 D3 공식 홈페이지를 참고하여 대부분의 데이터를 시각화하는 것이 가능해 집니다. (단, 복잡한 인터랙션이 가능한 데이터는 추가적인 학습이 필요)
// data.json
[
{
"countries": [
{
"continent": "africa",
"country": "Congo, Rep.",
"income": 576,
"life_exp": 32.7,
"population": 314465
},
{
"continent": "asia",
"country": "Vietnam",
"income": 861,
"life_exp": 32,
"population": 6551000
},
{
"continent": "asia",
"country": "South Korea",
"income": 575,
"life_exp": 25.8,
"population": 9395000
}
// ...
],
"year": 1800
},
// ...
{
// ...
"year": 2014
}
]
각 연도별 국가, 수입, 수명, 인구 수가 있는 데이터를 표현하고 싶은데 3가지 정보를 표현해야 하니 일반적으로 생각할 수 있는 Bar Chart는 적합하지 않습니다.
3개의 Bar를 붙이거나 각각의 정보별 차트를 나눠도 되지만, 국가가 약 200개 정도로 많다면 차트의 모양이 의도한대로 나오기 어렵습니다.
여기선 X축, Y축, 원의 크기 3가지로 차트를 그려줄 수 있는 Scatter Plot Chart로 결정하겠습니다.
const MARGIN = { LEFT: 100, TOP: 10, RIGHT: 10, BOTTOM: 100 };
const WIDTH = 960 - MARGIN.LEFT - MARGIN.RIGHT;
const HEIGHT = 500 - MARGIN.TOP - MARGIN.BOTTOM;
const svg = d3
.select("#chart-area")
.append("svg")
.attr("width", WIDTH + MARGIN.LEFT + MARGIN.RIGHT)
.attr("height", HEIGHT + MARGIN.TOP + MARGIN.BOTTOM);
const g = svg
.append("g")
.attr("transform", `translate(${MARGIN.LEFT}, ${MARGIN.TOP})`);
const x = d3.scaleLog().domain([142, 150000]).range([0, WIDTH]);
const y = d3.scaleLinear().domain([0, 90]).range([HEIGHT, 0]);
const area = d3.scaleLinear().domain([2000, 1400000000]).range([25, 1500]);
const continentColor = d3.scaleOrdinal(d3.schemePastel1);
d3.scaleLog()
: 로그 스케일 척도를 생성합니다. 여기서 input 값으로 기대되는 최소값, 최대값을 domain의 인자로 넘겨주는데, Log(0)은 정의되지 않은 값이므로 0보다 큰 값을 최소값으로 지정해야 합니다.(domain([0, 100])
형태로 넘겨주면 에러 발생)d3.scaleLinear()
: 선형 척도를 생성합니다.Math.sqrt()
메서드를 활용하기 위해 일부러 값을 크게 생성합니다.d3.scaleOrdinal()
: 지정된 범위와 영역을 갖는 서수 척도를 생성합니다.asia
, europe
, americas
, africa
4개의 정보가 있는데, 각 대륙별로 색상을 다르게 가져가기 위해 사용합니다.d3.schemePastel1
은 d3에서 제공하는 색상 스키마입니다.const xAxis = d3.axisBottom(x).ticks(3).tickFormat(d3.format("$"));
g.append("g").attr("transform", `translate(0, ${HEIGHT})`).call(xAxis);
const yAxis = d3.axisLeft(y);
g.append("g").call(yAxis);
d3.json("data/data.json").then((data) => {
const formattedData = data.map((year) =>
year.countries.filter((country) => country.income && country.life_exp)
);
g.selectAll("circle")
.data(formattedData[0])
.join("circle")
.attr("cx", (d) => x(d.income))
.attr("cy", (d) => y(d.life_exp))
.attr("r", (d) => Math.sqrt(area(d.population)))
.attr("fill", (d) => continentColor(d.continent));
});
d3.json
메서드는 Promise 기반입니다. async, await 키워드와 함께 사용 가능합니다.d3.data().join()
함수로 데이터를 d3와 연결시키고, 각각을 익명 함수로 넘겨 척도를 활용해 시각화 합니다.let time = 0;
d3.json("data/data.json").then((data) => {
const formattedData = data.map((year) =>
year.countries.filter((country) => country.income && country.life_exp)
);
const n = formattedData.length;
const interval = d3.interval(() => {
update(formattedData[time]);
time++;
if (time === n) {
interval.stop();
}
}, 100);
});
function update(data) {
g.selectAll("circle")
.data(data)
.join("circle")
.attr("fill", (d) => continentColor(d.continent))
.transition()
.duration(100)
.attr("cx", (d) => x(d.income))
.attr("cy", (d) => y(d.life_exp))
.attr("r", (d) => Math.sqrt(area(d.population)));
}
d3.interval()
반환 값을 변수에 할당하고, interval.stop()
메서드를 사용하면 됩니다.그런데 여기서 의도하지 않은 문제가 발생합니다.
interval 내에서 update를 정상적으로 실행시키고 있지만, 데이터가 중구난방으로 마구 움직입니다.
이를 해결하기 위해선 D3가 각각의 데이터를 그려줄 때 그 데이터가 동일한 값임을(위의 예제에서 한국임을) 알려주기 위해 기준이되는 값을 data의 두 번째 인자로 넘겨줍니다.
g.selectAll("circle")
.data(data, (d) => d.country)
.join("circle")
.attr("fill", (d) => continentColor(d.continent))
.transition()
.duration(100)
.attr("cx", (d) => x(d.income))
.attr("cy", (d) => y(d.life_exp))
.attr("r", (d) => Math.sqrt(area(d.population)));
그러면 country를 기준으로 값의 변화를 정확하게 해석해서 데이터 변화를 시각화해줍니다. 한국 1800년 GDP: 576 > 1801년 GDP: 575 로 1 감소된 값으로 해석하고, 해당 값이 transition으로 변화할 때 576 > 575로 이동하게 됩니다.
코인 입출금 데이터들을 가지고 데이터 시각화하는 방법을 알아보기 위해 캔들이 있는 차트를 만들어 보겠습니다.
[
{
"time": 1671607800000,
"open": 693.5,
"high": 699.5,
"low": 684.5,
"close": 695.5,
"volume": 105607.8043
}
// ...
]
여기서 각 값이 의미하는 것은 아래 그림과 같습니다.
const MARGIN = { LEFT: 50, TOP: 50, RIGHT: 50, BOTTOM: 50 };
const WIDTH = window.innerWidth - MARGIN.LEFT - MARGIN.RIGHT;
const HEIGHT = window.innerHeight - MARGIN.TOP - MARGIN.BOTTOM;
const svg = d3
.select("#chart-area")
.append("svg")
.attr("width", WIDTH + MARGIN.LEFT + MARGIN.RIGHT)
.attr("height", HEIGHT + MARGIN.TOP + MARGIN.BOTTOM);
const g = svg
.append("g")
.attr("transform", `translate(${MARGIN.LEFT}, ${MARGIN.TOP})`);
async function makeChart() {
const data = (await d3.json("data/data.json")).map((loadedData) => ({
...loadedData,
time: new Date(loadedData.time),
}));
// 시간(x축) 최소, 최대
const [xMin, xMax] = d3.extent(data, (d) => d.time);
// 아래 두 줄을 작성한 것과 동일합니다.
// const xMin = d3.min(data, (d) => d.time);
// const xMax = d3.max(data, (d) => d.time);
// 가격(y축) 값 최소, 최대
const yMin = d3.min(data, (d) => d.low);
const yMax = d3.max(data, (d) => d.high);
// Scale
const x = d3.scaleTime().domain([xMin, xMax]).range([0, WIDTH]);
const y = d3.scaleLinear().domain([yMin, yMax]).range([HEIGHT, 0]);
}
scaleTime()
) 를 사용할 수 있으므로 time 값을 Date 객체로 변환(Array.map
)해 줍니다.d3.min
, d3.max
로 데이터 중 최소, 최대값을 구할 수도 있고, d3.extent
메서드를 통해 한 번에 [min, max] 값을 구할 수도 있습니다.// Axis
const xAxis = d3.axisBottom(x);
g.append("g").attr("transform", `translate(0, ${HEIGHT})`).call(xAxis);
const yAxis = d3.axisRight(y);
g.append("g").attr("transform", `translate(${WIDTH}, 0)`).call(yAxis);
// Rect
const barWidth = WIDTH / data.length - 0.5;
const rects = g.append("g").selectAll("rect").data(data);
rects
.join("rect")
.attr("x", (d) => x(d.time))
.attr("y", (d) => y(d.open > d.close ? d.open : d.close))
.attr("width", barWidth)
.attr("height", (d) => Math.abs(y(d.open) - y(d.close)))
.attr("fill", (d) => (d.open - d.close > 0 ? "#ff4e4e" : "#4ffb9c"));
d3.data().join()
메서드로 데이터를 조인한 후 Rect 태그를 그려줍니다.// Line (Small Rect)
const lines = g.append("g").selectAll("rect").data(data);
const lineWidth = barWidth / 4;
const offset = (3 / 2) * lineWidth;
lines
.join("rect")
.attr("x", (d) => x(d.time) + offset)
.attr("y", (d) => y(d.high))
.attr("width", lineWidth)
.attr("height", (d) => y(d.low) - y(d.high))
.attr("fill", (d) => (d.open - d.close > 0 ? "#ff4e4e" : "#4ffb9c"));
d3.line()
메서드가 있지만, d3.line()
메서드는 HTML path 태그의 d 속성에 부여하는 path를 반환하거나, d3.line()([x1, y1], [x2, y2])
형태로 넘겨줘야 하는데, 그러기엔 메서드가 중첩되어 보기 좋지 않습니다.function getMovingAverageData(data, average) {
return data.map((row, index, total) => {
const start = Math.max(0, index - average);
const end = index;
const subset = total.slice(start, end + 1);
const sum = subset.reduce((a, b) => a + b.close, 0);
return {
time: row.time,
average: sum / subset.length,
};
});
}
// Moving Average
const average20 = getMovingAverageData(data, 20);
const average50 = getMovingAverageData(data, 50);
const averageLine = d3
.line()
.x((d) => x(d.time))
.y((d) => y(d.average));
g.append("path")
.attr("fill", "none")
.attr("stroke", "#FFFBEB")
.attr("stroke-width", 1)
.attr("d", averageLine(average20));
g.append("path")
.attr("fill", "none")
.attr("stroke", "#F56EB3")
.attr("stroke-width", 1)
.attr("d", averageLine(average50));
d3.line()
태그는 함수처럼 활용이 가능하고, 여기에 data를 인자로 넘겨주면 해당 data에 따라 path를 그려줍니다.console.log(averageLine(data)); // M0,375.19213882L1,...
// Scale
const y = d3
.scaleLinear()
.domain([yMin - 80, yMax]) // 기존 척도에서 아래 80px만큼 여유 공간을 줍니다.
.range([HEIGHT, 0]);
// ...
// Volume
const yVolume = d3
.scaleLog()
.domain(d3.extent(data, (d) => d.volume))
.range([100, 0]);
g.append("g")
.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (d) => x(d.time))
.attr("y", (d) => HEIGHT - yVolume(d.volume))
.attr("width", barWidth)
.attr("height", (d) => yVolume(d.volume))
.attr("fill", (d, i) => {
if (i === 0) {
return "rgb(143 45 45)";
} else {
return data[i - 1].volume < d.volume
? "rgb(143 45 45)"
: "rgb(45 160 97)";
}
});