|
1 | 1 | import React, { useEffect, useRef } from "react"; |
2 | 2 | import * as d3 from "d3"; |
3 | 3 |
|
4 | | -const BubbleChart = ({ keywordCounts, onKeywordClick }) => { |
5 | | - const svgRef = useRef(null); |
| 4 | +const BubbleChart = ({ keywordCounts }) => { |
| 5 | + const svgRef = useRef(); |
6 | 6 |
|
7 | 7 | useEffect(() => { |
8 | 8 | if (!keywordCounts || Object.keys(keywordCounts).length === 0) return; |
9 | 9 |
|
10 | | - const data = Object.entries(keywordCounts) |
11 | | - .map(([keyword, count]) => ({ name: keyword, value: count })) |
12 | | - .slice(0, 10); // μμ 10κ°λ§ νμ |
| 10 | + d3.select(svgRef.current).selectAll("*").remove(); // κΈ°μ‘΄ μ°¨νΈ μ κ±° |
13 | 11 |
|
14 | | - const width = 800; |
15 | | - const height = 600; |
16 | | - const maxRadius = 150; |
| 12 | + const width = 600, |
| 13 | + height = 500; |
17 | 14 |
|
| 15 | + // πΉ λ°μ΄ν° μ ν¨μ± κ²μ¬ (NaN μ κ±°) |
| 16 | + const validData = Object.entries(keywordCounts) |
| 17 | + .filter(([name, size]) => typeof size === "number" && !isNaN(size)) // NaN μ κ±° |
| 18 | + .map(([name, size]) => ({ name, size })); |
| 19 | + |
| 20 | + if (validData.length === 0) return; // μ ν¨ν λ°μ΄ν° μμΌλ©΄ μ°¨νΈ λ λλ§ X |
| 21 | + |
| 22 | + // πΉ μμ μ€μΌμΌ: κ°μ΄ ν΄μλ‘ μ§ν νλμ μ μ© |
| 23 | + const minSize = d3.min(validData, (d) => d.size); |
| 24 | + const maxSize = d3.max(validData, (d) => d.size); |
18 | 25 | const colorScale = d3 |
19 | 26 | .scaleSequential(d3.interpolateBlues) |
20 | | - .domain([ |
21 | | - Math.min(...data.map((d) => d.value)), |
22 | | - Math.max(...data.map((d) => d.value)), |
23 | | - ]); |
| 27 | + .domain([minSize, maxSize]); |
| 28 | + |
| 29 | + // D3 pack layout μ¬μ© |
| 30 | + const bubble = d3.pack().size([width, height]).padding(2); |
| 31 | + |
| 32 | + const root = d3 |
| 33 | + .hierarchy({ children: validData }) |
| 34 | + .sum((d) => d.size) |
| 35 | + .sort((a, b) => b.value - a.value); |
24 | 36 |
|
25 | | - const bubble = d3.pack().size([width, height]).padding(5); |
26 | | - const root = d3.hierarchy({ children: data }).sum((d) => d.value); |
27 | 37 | bubble(root); |
28 | 38 |
|
29 | 39 | const svg = d3 |
30 | 40 | .select(svgRef.current) |
31 | | - .attr("viewBox", `0 0 ${width} ${height}`) |
32 | 41 | .attr("width", width) |
33 | 42 | .attr("height", height) |
34 | 43 | .attr("class", "bubble"); |
35 | 44 |
|
36 | 45 | const nodes = svg |
37 | 46 | .selectAll(".node") |
38 | 47 | .data(root.children) |
39 | | - .join("g") |
| 48 | + .enter() |
| 49 | + .append("g") |
40 | 50 | .attr("class", "node") |
41 | | - .attr("transform", (d) => `translate(${d.x}, ${d.y})`) |
42 | | - .style("cursor", "pointer") |
43 | | - .on("click", (event, d) => { |
44 | | - if (onKeywordClick) { |
45 | | - onKeywordClick(d.data.name); |
46 | | - } |
47 | | - }); |
| 51 | + .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`); // πΉ NaN λ°©μ§ |
48 | 52 |
|
49 | 53 | nodes |
50 | 54 | .append("circle") |
51 | | - .attr("r", (d) => Math.min(d.r, maxRadius)) |
52 | | - .attr("fill", (d) => colorScale(d.data.value)); |
| 55 | + .attr("r", (d) => d.r || 10) // πΉ NaN λ°©μ§: μ΅μ ν¬κΈ° μ§μ |
| 56 | + .style("fill", (d) => colorScale(d.data.size)) // β
κ°μ΄ ν΄μλ‘ μ§ν νλμ μ μ© |
| 57 | + .style("opacity", 0.8) |
| 58 | + .transition() |
| 59 | + .duration(1000) |
| 60 | + .attr("r", (d) => d.r || 10); |
53 | 61 |
|
54 | 62 | nodes |
55 | 63 | .append("text") |
56 | 64 | .attr("dy", ".3em") |
57 | 65 | .attr("text-anchor", "middle") |
58 | | - .text((d) => d.data.name) |
59 | | - .attr("fill", "#fff") |
60 | | - .style("font-size", (d) => `${Math.max(d.r / 5, 12)}px`) |
61 | | - .style("font-weight", "bold"); |
62 | | - }, [keywordCounts, onKeywordClick]); |
| 66 | + .style("fill", "#fff") |
| 67 | + .style("font-size", (d) => Math.max(10, d.r / 4) + "px") |
| 68 | + .text((d) => d.data.name); |
| 69 | + }, [keywordCounts]); |
63 | 70 |
|
64 | | - return <svg ref={svgRef}></svg>; |
| 71 | + return <svg ref={svgRef} />; |
65 | 72 | }; |
66 | 73 |
|
67 | 74 | export default BubbleChart; |
0 commit comments