前言
資料視覺化(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;
}
這個力導向圖的物理模型包含三種力:
- 排斥力(反平方定律):所有節點互相排斥,防止重疊
- 吸引力(虎克定律):有邊連接的節點互相吸引
- 中心力:所有節點被輕輕拉向畫布中心
時間序列的藝術化呈現
用溫度資料做一個「年輪」式的視覺化:
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);
}
小結
資料視覺化不是只有長條圖和折線圖。當你用藝術的眼光來看待數據,每一組數字都是創作的素材。
這篇文章我們學了:
loadTable()和loadJSON()載入外部資料map()函數做數值到視覺的映射- 藝術化的圓形圖和放射狀呈現
- 力導向圖展示關聯性
- 時間序列的年輪式視覺化
- 資料驅動的粒子系統
- 從網路 API 載入即時資料
我覺得最好的資料視覺化,是那種你看了之後會說「哦,原來是這樣」的作品——它不只是把數字變成圖形,而是幫你看見數據背後的故事和模式。
延伸閱讀
- 《Dear Data》(Giorgia Lupi & Stefanie Posavec) — 手繪資料視覺化的經典之作
- 《The Visual Display of Quantitative Information》(Edward Tufte) — 資訊視覺化的聖經
- Observable / D3.js — 更專業的資料視覺化框架
- p5.js loadTable / loadJSON 官方文件
- Kaggle — 免費的資料集,拿來練習視覺化