繪製圖表是常見的程式需求,過去我寫過不少相關文章:

就語言生態系及程式庫成熟度來說,Python 應是首選,但在某些特殊情境,有些解決方案更能展現威力。今天就來介紹在網頁產生圖表的開源利器 - D3.js

D3.js 的命名源自 Data-Driven Documents,它是專為網頁開發設計的 JavaScript 資料視覺化函式庫,能利用 SVG、HTML5 和 CSS 動態生成互動式圖表、地圖等資料視覺化元件。

D3.js 核心精神是將資料繫結到 SVG 元素,根據資料自動產生、更新或移除圖形。它的 API 用起來有點像 jQuery,用 select()/selectAll() 選元素,attr() 設屬性,並透過串接完成動作。例如:d3.select("body").append("p").text("Hello World!");,對於有 jQuery 經驗的人應格外有親切感。

而除了提供各式現成圖表範例,D3.js 的 API 能充分支持開發特規圖表或加入高度客製的互動操作,換句話說,你的 HTML/CSS/JavaScript 技能水準決定 D3.js 的能力極限。

總之,如果想為網頁加上可互動操作的資料圖表,D3.js 這個 ISC 授權(相當於 BSD/MIT,可免費商用)的開源程式庫將是好選擇。既是前端攻城利器,焉有不學的道理?

於是,我試著用 D3.js 做了簡單的網頁長條圖當成練習,顯示由亂數產生的 5、10 或 20 筆資料;網頁並支援簡單互動操作,滑鼠滑過長條時右側表格的對應項目會變色;反之,當滑鼠滑過表格數字,對應的長條也會變色。

這個 D3.js 圖表練習成果如下:

必須說,D3.js 雖然看起來像 jQuery,但必須學會一些 D3.js 獨有的觀念才能運用自如。當然,如果你不想弄懂,打算全部丟給 AI 生,那就是選擇了另一條路線,也就不必繼續往下讀,我們下回再見! (揮手下降)

(揮手上升) 嘿,沒想到還有這麼多同學留下來。想必大家應該是有興趣看懂程式或自己動手寫的勇者,那在此簡單交待這張長條圖的做法。

D3.js 的基本教學多如牛毛,就算不想花時間讀,請 Copilot 給範例再要他解釋到飽,也能學到會。AI 造就了學寫程式的夢幻環境,也讓許多人慶幸未來再也不用寫程式,想寫程式跟不想寫程式的人都開心,也算皆大歡喜,噗。

以下是我本次學到的重點:(為學習 D3.js 運作原理,這裡不用現成長條圖套件,從頭自己刻一次)

  • 長條圖的 X 軸需要一個 Band Scaler,將離散資料(如類別、索引)平均分配計算其對應的長條位置,考慮間距後決定寬度。例如:總寬度 400,間距 0.25,則每個區塊寬度為 (400 - 40 - 30) / 9 = 40。
  • 長條圖的 Y 軸則是使用 Linear Scaler,提供極大值決定區間(有個 nice() 可將 97 調校為 100 等適合人類閱讀的刻度數字),以使將資料數值換算成 Y 座標。
  • 縮製長條的方法是在 svg.selectAll('rect').data(data).join() 為每筆資料建立 rect 元素,接著 .attr("x", (d, i) => x(i)).attr("y", d => y(d.c)).attr("height", d => y(0) - y(d.c)).attr("width", x.bandwidth()) 決定 rect 的 X, Y 座標及寬、高。
  • rect 是標準 HTML 元素,故用 :hover CSS 設定滑鼠移過時的樣式,也可掛上 mouseover/mouseout 事件加上互動邏輯。
  • 產生 X 軸刻度的做法是產生一個 g 群組元素,.attr("transform", `translate(0,${height - margin.bottom})`) 決定位置,.call(d3.axisBottom(x).tickFormat((i) => data[i]?.n ?? "")) 將 g 傳給 axisBottom(),用 X Band Scaler 設定 X 軸刻度位置。
  • 產生 Y 軸刻度也是產生 g 元素,但改呼叫 .call(d3.axisLeft(y).ticks(5));,將 g 傳給 axisLeft(),用 Y Linear Scaler 設定 Y 軸刻度,並指定分成五份,產生五個刻度。
  • 產生隨機資料、用 <table> 列出資料的部分我用 Vue.js MVVM 寫,至於要跟 D3.js 元素互動,呼叫 d3.select() 再用類似 jQuery 的寫法即可 d3.select(`rect[data-n='${n}']`).classed('active', true);

程式碼如下,全部不到 200 行搞定,我也放了線上版讓大家試玩:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>D3.js Bar Plot Example</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
    <style>
        .bar {
            fill: steelblue;
        }

        .bar:hover, .bar.active {
            fill: orange;
        }

        .axis-label {
            font-size: 12px;
        }

        .op {
            label {
                cursor: pointer;
            }

            button {
                margin-left: 8px;
            }
        }

        .view {
            display: flex;
            flex-direction: row;
            align-items: center;

            .table {
                display: flex;
                flex-direction: row;
            }

            table {
                margin-left: 10px;
                border-collapse: collapse;
                width: 100px;

                td {
                    border: 1px solid #ccc;
                    text-align: center;
                }

                tr.focus {
                    background-color: yellow;
                }
            }
        }
    </style>
</head>

<body>
    <div id="app">
        <div class="op">
            <label v-for="count in dataCounts" :key="count">
                <input type="radio" v-model="dataCount" :value="count" />
                {{ count }}
            </label>
            <button @click="genData(dataCount)">Generate</button>
            <button @click="sortData()">Sort</button>
        </div>
        <div class="view">
            <svg width="400" height="260"></svg>
            <div class="table">
                <table>
                    <tbody>
                        <tr v-for="(item, index) in data.slice(0, 10)" :class="{ 'focus': focusIdx === item.n }"
                            @mouseover="focusBar(item.n)" @mouseout="unfocusBar(item.n)">
                            <td>{{ item.n }}</td>
                            <td>{{ item.c }}</td>
                        </tr>
                    </tbody>
                </table>
                <table v-if="data.length > 10">
                    <tbody>
                        <tr v-for="(item, index) in data.slice(10)" :class="{ 'focus': focusIdx === item.n }"
                            @mouseover="focusBar(item.n)" @mouseout="unfocusBar(item.n)">
                            <td>{{ item.n }}</td>
                            <td>{{ item.c }}</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
    <script>
        function showData(data) {
            const svg = d3.select("svg"),
                width = +svg.attr("width"),
                height = +svg.attr("height"),
                margin = { top: 20, right: 30, bottom: 30, left: 40 };

            svg.selectAll("*").remove();

            // 建立 X 軸 Band Scaler (帶狀比例尺)函數
            // 將離散資料(如類別、索引)平均分配到一段連續像素範圍,並自動處理每個區塊的寬度與間距
            // 例如:總寬度 400,間距 0.25,則每個區塊寬度為 (400 - 40 - 30) / 9 = 40
            const x = d3.scaleBand()
                // 使用元素索引作為 x 軸
                .domain(data.map((d, i) => i))
                .range([margin.left, width - margin.right])
                // 設定間距
                .padding(0.25);

            // 建立 Y 軸 Linear Scaler (線性比例尺)函數
            const y = d3.scaleLinear()
                // .nice() 會自動調整成易讀刻度,例如: 97 調成 100
                .domain([0, d3.max(data, d => d.c)]).nice()
                .range([height - margin.bottom, margin.top]);

            // 建立長條圖
            svg.append("g") // 在 SVG 中新增一個群組元素 g
                .attr("fill", "steelblue")
                .selectAll("rect") // 選擇所有 rect 元素,一開始是空集合
                .data(data) // 綁定資料,d3.js 決定產生多少個 rect
                .join("rect") // 依據資料新增或移除對應數量的 rect
                .attr("class", "bar")
                .attr("data-n", (d, i) => d.n)
                .attr("x", (d, i) => x(i)) // 用 Band Scaler 函數算出索引對應的 X 軸位置
                .attr("y", d => y(d.c))   // 用 Linear Scaler 函數算出筆數對應的 Y 軸的位置
                .attr("height", d => y(0) - y(d.c)) // 長條圖的高度
                .attr("width", x.bandwidth()) // 長條寬度 (考量 padding)
                // 互動事件
                .on('mouseover', function (event, d) {
                    vm.focusIdx = d.n;
                })
                .on('mouseout', function (event, d) {
                    vm.focusIdx = -1;
                });

            // 加入 X 軸
            svg.append("g")
                .attr("transform", `translate(0,${height - margin.bottom})`)
                // 將 g 傳給 axisBottom(),用 X Band Scaler 設定 X 軸刻度位置
                .call(d3.axisBottom(x).tickFormat((i) => data[i]?.n ?? ""))
                .selectAll("text")
                .attr("class", "axis-label")
                .attr("transform", "rotate(-0)")
                .style("text-anchor", "middle");

            // 加入 Y 軸
            svg.append("g")
                .attr("transform", `translate(${margin.left},0)`)
                // 將 g 傳給 axisLeft(),用 Y Linear Scaler 設定 Y 軸標記
                // .ticks(n) 可指定 Y 軸分成 n 等份
                .call(d3.axisLeft(y).ticks(5));
        }
    </script>
    <script>
        const app = Vue.createApp({
            data() {
                return {
                    data: [],
                    dataCount: 10,
                    dataCounts: [5, 10, 20],
                    focusIdx: -1,
                }
            },
            methods: {
                sortData() {
                    const sorted = this.data.slice().sort((a, b) => b.c - a.c);
                    showData(sorted);
                },
                genData(range) {
                    this.data = Array.from({ length: range }, (_, i) => ({
                        n: i + 1,
                        c: Math.floor(Math.random() * 512)
                    }));
                    if (this.sort) {
                        this.sortData();
                    }
                    showData(this.data);
                },
                focusBar(n) {
                    this.focusIdx = n;
                    d3.select(`rect[data-n='${n}']`).classed('active', true);
                },
                unfocusBar(n) {
                    this.focusIdx = -1;
                    d3.select(`rect[data-n='${n}']`).classed('active', false);
                }
            }
        });
        const vm = app.mount('#app');
        vm.genData(vm.dataCount);
    </script>

</body>

</html>

就醬,正式將 D3.js 收入我的前端工具箱。

This blog introduces D3.js for web-based interactive data visualization. It highlights essential concepts and provides a simple bar chart example with JavaScript and Vue.js integration.


Comments

Be the first to post a comment

Post a comment