前言

資料視覺化(Data Visualization)通常讓人聯想到商業報表、折線圖、長條圖。但如果你用創意程式設計的眼光來看,數據其實是一種非常有趣的創作素材

一組天氣數據可以變成一幅抽象畫。一份城市人口統計可以化身為粒子模擬。一串時間序列可以轉化為音樂般的節奏視覺。資料藝術化(Data Art)是資訊視覺化和生成式藝術的交匯點——它不只是讓你「看懂」數據,更讓你「感受」數據。

p5.js 內建了 loadTable()loadJSON() 等函數,讓你輕鬆載入外部資料。配合我們前面學過的粒子系統、流場、互動技巧,你可以把枯燥的數字變成令人驚艷的視覺作品。

這篇文章會帶你從資料載入開始,探索各種藝術化的數據呈現方式。


loadTable:載入 CSV 資料

CSV(逗號分隔值)是最常見的資料格式之一。p5.js 的 loadTable() 可以直接把它讀成表格物件:

let table;

function preload() { // 第三個參數 'header' 表示第一行是欄位名稱 table = loadTable('data.csv', 'csv', 'header'); }

function setup() { createCanvas(800, 600);

// 基本操作 console.log('Rows:', table.getRowCount()); console.log('Columns:', table.getColumnCount()); console.log('Column names:', table.columns);

// 取得特定欄位的所有值 let names = table.getColumn('name'); let values = table.getColumn('value');

// 遍歷每一行 for (let row of table.rows) { let name = row.getString('name'); let value = row.getNum('value'); console.log(name, value); } }

假設我們有一份城市溫度資料 temperature.csv

city,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec
Taipei,16.1,16.5,18.5,21.9,25.2,27.7,29.6,29.2,27.4,24.5,21.5,17.9
Tokyo,5.2,5.7,8.7,13.9,18.2,21.4,25.0,26.4,22.8,17.5,12.1,7.6
London,5.2,5.3,7.4,9.7,13.0,16.0,18.4,18.0,15.2,11.8,8.0,5.5

loadJSON:載入 JSON 資料

JSON 更適合巢狀結構的資料:

let data;

function preload() { data = loadJSON('data.json'); }

function setup() { createCanvas(800, 600);

// JSON 直接變成 JavaScript 物件 for (let item of data.items) { console.log(item.name, item.value); } }

範例 JSON:

{
  "items": [
    { "name": "JavaScript", "popularity": 95, "category": "web" },
    { "name": "Python", "popularity": 90, "category": "data" },
    { "name": "Rust", "popularity": 65, "category": "system" },
    { "name": "Go", "popularity": 70, "category": "cloud" }
  ]
}

資料映射的基本技巧

資料視覺化的核心就是映射(mapping)——把數據的值域映射到視覺的屬性上。p5.js 的 map() 函數是你最好的朋友:

// 溫度 → 顏色
let temp = 28;  // 攝氏
let r = map(temp, 0, 40, 50, 255);   // 越熱越紅
let b = map(temp, 0, 40, 255, 50);   // 越熱越不藍
fill(r, 100, b);

// 人口 → 圓的大小 let population = 2700000; let diameter = map(population, 0, 10000000, 10, 200); ellipse(x, y, diameter);

// 時間 → 角度 let month = 6; // 六月 let angle = map(month, 1, 12, 0, TWO_PI);

// GDP → 透明度 let gdp = 35000; let alpha = map(gdp, 0, 80000, 50, 255);

常用的映射對應:
| 數據屬性 | 視覺屬性 |
|———|———|
| 數值大小 | 圓的面積、長條高度 |
| 類別 | 顏色色相 |
| 時間順序 | X 軸位置、角度 |
| 重要性/排名 | 透明度、亮度 |
| 關聯性 | 連線、距離 |


藝術化的圓形圖

傳統的圓餅圖很無聊。讓我們做一個帶有節奏感的放射狀資料圖:

let data;

function preload() { data = loadJSON('languages.json'); }

function setup() { createCanvas(800, 800); noLoop(); }

function draw() { background(30, 30, 40);

let items = data.items; let cx = width / 2; let cy = height / 2; let maxRadius = 300;

// 排序(由大到小) items.sort((a, b) => b.popularity - a.popularity);

// 繪製同心環 for (let i = 0; i < items.length; i++) { let item = items[i]; let radius = map(i, 0, items.length, maxRadius, 80); let arcLength = map(item.popularity, 0, 100, 0, TWO_PI);

// 每個項目的起始角度稍有偏移 let startAngle = -HALF_PI + i * 0.2;

// 彩色弧線 let hue = map(i, 0, items.length, 0, 300); colorMode(HSB, 360, 100, 100); noFill(); stroke(hue, 70, 90, 80); strokeWeight(20); strokeCap(ROUND); arc(cx, cy, radius 2, radius 2, startAngle, startAngle + arcLength);

// 標籤 let labelAngle = startAngle + arcLength + 0.1; let labelX = cx + cos(labelAngle) * (radius + 20); let labelY = cy + sin(labelAngle) * (radius + 20);

fill(0, 0, 90); noStroke(); textSize(12); textAlign(LEFT, CENTER); text(item.name + ' (' + item.popularity + ')', labelX, labelY);

colorMode(RGB); }

// 中央標題 fill(255); textSize(18); textAlign(CENTER, CENTER); text('Programming\nLanguages', cx, cy); }

放射狀點陣圖

把數值用點的密度來表示:

function drawRadialDots(cx, cy, radius, value, maxValue, col) {
  let dotCount = floor(map(value, 0, maxValue, 5, 200));

for (let i = 0; i < dotCount; i++) { let angle = random(TWO_PI); let r = random(radius * 0.3, radius); let x = cx + cos(angle) * r; let y = cy + sin(angle) * r; let size = random(1, 4);

fill(red(col), green(col), blue(col), random(100, 255)); noStroke(); ellipse(x, y, size); } }


力導向圖(Force-Directed Graph)

力導向圖是展示關聯性資料的經典手法。節點之間有「吸引力」(相關)和「排斥力」(不相關),讓它們自然排列:

let nodes = [];
let edges = [];

function setup() { createCanvas(800, 600);

// 建立節點 let names = ['JavaScript', 'TypeScript', 'React', 'Vue', 'Node.js', 'Python', 'Django', 'Flask', 'TensorFlow', 'Rust'];

for (let name of names) { nodes.push({ label: name, pos: createVector(random(200, width - 200), random(200, height - 200)), vel: createVector(0, 0), radius: 25 }); }

// 建立邊(關聯性) edges = [ [0, 1], [0, 2], [0, 3], [0, 4], // JS 相關 [1, 2], // TS-React [5, 6], [5, 7], [5, 8], // Python 相關 [0, 5], // JS-Python [4, 5], // Node-Python ]; }

function draw() { background(30, 30, 40);

// 物理模擬 applyForces();

// 繪製邊 stroke(100, 150, 200, 80); strokeWeight(1); for (let [a, b] of edges) { line(nodes[a].pos.x, nodes[a].pos.y, nodes[b].pos.x, nodes[b].pos.y); }

// 繪製節點 for (let node of nodes) { fill(60, 60, 80); stroke(100, 180, 255); strokeWeight(2); ellipse(node.pos.x, node.pos.y, node.radius * 2);

fill(220); noStroke(); textAlign(CENTER, CENTER); textSize(10); text(node.label, node.pos.x, node.pos.y); } }

function applyForces() { // 節點之間的排斥力(Coulomb's law) for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { let force = p5.Vector.sub(nodes[i].pos, nodes[j].pos); let d = max(force.mag(), 30); force.normalize(); force.mult(2000 / (d * d)); // 反平方定律

nodes[i].vel.add(force); nodes[j].vel.sub(force); } }

// 邊的吸引力(Hooke's law) for (let [a, b] of edges) { let force = p5.Vector.sub(nodes[b].pos, nodes[a].pos); let d = force.mag(); let stretch = d - 100; // 理想距離 100 force.normalize(); force.mult(stretch * 0.005);

nodes[a].vel.add(force); nodes[b].vel.sub(force); }

// 向中心的力(防止飄遠) let center = createVector(width / 2, height / 2); for (let node of nodes) { let toCenter = p5.Vector.sub(center, node.pos); toCenter.mult(0.001); node.vel.add(toCenter); }

// 更新位置 for (let node of nodes) { node.vel.mult(0.85); // 阻尼 node.pos.add(node.vel);

// 邊界約束 node.pos.x = constrain(node.pos.x, 50, width - 50); node.pos.y = constrain(node.pos.y, 50, height - 50); } }

// 可拖曳節點 let dragged = null;

function mousePressed() { for (let node of nodes) { if (dist(mouseX, mouseY, node.pos.x, node.pos.y) < node.radius) { dragged = node; break; } } }

function mouseDragged() { if (dragged) { dragged.pos.set(mouseX, mouseY); dragged.vel.set(0, 0); } }

function mouseReleased() { dragged = null; }

這個力導向圖的物理模型包含三種力:

  1. 排斥力(反平方定律):所有節點互相排斥,防止重疊
  2. 吸引力(虎克定律):有邊連接的節點互相吸引
  3. 中心力:所有節點被輕輕拉向畫布中心

時間序列的藝術化呈現

用溫度資料做一個「年輪」式的視覺化:

let table;

function preload() { table = loadTable('temperature.csv', 'csv', 'header'); }

function setup() { createCanvas(800, 800); noLoop(); }

function draw() { background(30, 30, 40);

let cx = width / 2; let cy = height / 2; let months = ['jan','feb','mar','apr','may','jun', 'jul','aug','sep','oct','nov','dec'];

for (let i = 0; i < table.getRowCount(); i++) { let row = table.rows[i]; let city = row.getString('city'); let baseRadius = 100 + i * 80;

// 每個城市畫一圈 beginShape(); noFill();

for (let m = 0; m <= 12; m++) { let monthIndex = m % 12; let temp = row.getNum(months[monthIndex]); let angle = map(m, 0, 12, 0, TWO_PI) - HALF_PI; let radius = baseRadius + map(temp, -5, 35, -30, 30);

// 溫度 → 顏色 let r = map(temp, -5, 35, 50, 255); let b = map(temp, -5, 35, 255, 50); stroke(r, 100, b, 200); strokeWeight(3);

let x = cx + cos(angle) * radius; let y = cy + sin(angle) * radius; curveVertex(x, y); } endShape();

// 城市標籤 fill(200); noStroke(); textSize(12); textAlign(LEFT, CENTER); text(city, cx + baseRadius + 40, cy); }

// 月份標籤 for (let m = 0; m < 12; m++) { let angle = map(m, 0, 12, 0, TWO_PI) - HALF_PI; let labelR = 80; let x = cx + cos(angle) * labelR; let y = cy + sin(angle) * labelR;

fill(150); textAlign(CENTER, CENTER); textSize(11); text(months[m].toUpperCase(), x, y); } }


資料驅動的粒子視覺化

把每筆資料變成一顆粒子,用物理模擬來呈現:

let dataParticles = [];

function preload() { data = loadJSON('languages.json'); }

function setup() { createCanvas(800, 600);

for (let item of data.items) { let size = map(item.popularity, 0, 100, 20, 80);

// 根據 category 分配目標位置 let targetX, targetY; switch (item.category) { case 'web': targetX = 200; targetY = 300; break; case 'data': targetX = 400; targetY = 300; break; case 'system': targetX = 600; targetY = 300; break; case 'cloud': targetX = 500; targetY = 200; break; default: targetX = 400; targetY = 400; }

dataParticles.push({ label: item.name, pos: createVector(random(width), random(height)), vel: createVector(0, 0), target: createVector(targetX + random(-50, 50), targetY + random(-50, 50)), size: size, popularity: item.popularity, category: item.category, color: getCategoryColor(item.category) }); } }

function getCategoryColor(category) { switch (category) { case 'web': return color(100, 200, 255); case 'data': return color(100, 255, 150); case 'system': return color(255, 150, 100); case 'cloud': return color(200, 150, 255); default: return color(200); } }

function draw() { background(30, 30, 40);

for (let p of dataParticles) { // 向目標位置移動 let force = p5.Vector.sub(p.target, p.pos); force.mult(0.03); p.vel.add(force); p.vel.mult(0.9); p.pos.add(p.vel);

// 粒子之間的排斥 for (let other of dataParticles) { if (other === p) continue; let d = dist(p.pos.x, p.pos.y, other.pos.x, other.pos.y); let minDist = (p.size + other.size) / 2 + 5; if (d < minDist) { let repel = p5.Vector.sub(p.pos, other.pos).normalize(); repel.mult((minDist - d) * 0.05); p.vel.add(repel); } }

// 繪製 fill(red(p.color), green(p.color), blue(p.color), 180); stroke(red(p.color), green(p.color), blue(p.color)); strokeWeight(1); ellipse(p.pos.x, p.pos.y, p.size);

// 標籤 fill(255); noStroke(); textAlign(CENTER, CENTER); textSize(max(p.size * 0.2, 9)); text(p.label, p.pos.x, p.pos.y); }

// 類別標題 fill(100); textSize(14); textAlign(CENTER); text('Web', 200, 450); text('Data Science', 400, 450); text('System', 600, 450); }


從 API 載入即時資料

p5.js 也可以從網路 API 載入資料:

let earthquakes;

function preload() { // USGS 地震資料 API let url = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson'; earthquakes = loadJSON(url); }

function setup() { createCanvas(800, 400); noLoop(); }

function draw() { background(30, 30, 40);

let features = earthquakes.features;

for (let quake of features) { let lon = quake.geometry.coordinates[0]; let lat = quake.geometry.coordinates[1]; let mag = quake.properties.mag;

// 經緯度 → 畫布座標(簡化的投影) let x = map(lon, -180, 180, 0, width); let y = map(lat, 90, -90, 0, height);

// 震級 → 大小和顏色 let size = map(mag, 0, 8, 3, 50); let r = map(mag, 0, 8, 100, 255); let alpha = map(mag, 0, 8, 80, 255);

fill(r, 80, 60, alpha); noStroke(); ellipse(x, y, size); }

fill(200); textSize(16); text('Earthquakes in the Last 24 Hours', 20, 30); text('Data: USGS', 20, 50); }


小結

資料視覺化不是只有長條圖和折線圖。當你用藝術的眼光來看待數據,每一組數字都是創作的素材。

這篇文章我們學了:

  1. loadTable()loadJSON() 載入外部資料
  2. map() 函數做數值到視覺的映射
  3. 藝術化的圓形圖和放射狀呈現
  4. 力導向圖展示關聯性
  5. 時間序列的年輪式視覺化
  6. 資料驅動的粒子系統
  7. 從網路 API 載入即時資料

我覺得最好的資料視覺化,是那種你看了之後會說「哦,原來是這樣」的作品——它不只是把數字變成圖形,而是幫你看見數據背後的故事和模式。

延伸閱讀

  • 《Dear Data》(Giorgia Lupi & Stefanie Posavec) — 手繪資料視覺化的經典之作
  • 《The Visual Display of Quantitative Information》(Edward Tufte) — 資訊視覺化的聖經
  • Observable / D3.js — 更專業的資料視覺化框架
  • p5.js loadTable / loadJSON 官方文件
  • Kaggle — 免費的資料集,拿來練習視覺化