3D 餅圖本質是**橢圓柱體切片的正投影**。每個扇區對應一個「楔形柱體」,由以下要素構成:
// 橢圓參數方程:x = a·cosθ, y = b·sinθ
// 扇形角度:$angle = ($value / $total) * 360
// 3D 側面:將頂面沿 Y 軸下移 $depth 像素
<?php
class PieChart3D {
private int $width;
private int $height;
private int $centerX;
private int $centerY;
private int $radiusX; // 橢圓長半軸
private int $radiusY; // 橢圓短半軸
private int $depth = 20; // 3D 厚度
private array $data = [];
private array $colors = [];
private array $labels = [];
private resource $image;
private int $total = 0;
public function __construct(int $width = 600, int $height = 400) {
$this->width = $width;
$this->height = $height;
$this->centerX = $width / 2;
$this->centerY = $height / 2;
$this->radiusX = min($width, $height) / 2 - 40;
$this->radiusY = (int)($this->radiusX * 0.5); // 橢圓扁平比例 0.5
}
public function setData(array $values, array $labels = [], array $colors = []): self {
$this->data = array_values($values);
$this->labels = $labels ?: array_keys($values);
$this->total = array_sum($this->data);
// 默認配色方案
$defaultColors = [
0xFF6B6B, 0x4ECDC4, 0xFFE66D, 0x95E1D3,
0xF38181, 0xAA96DA, 0xFCBAD3, 0xA8D8EA
];
$this->colors = $colors ?: array_slice($defaultColors, 0, count($this->data));
return $this;
}
public function setDepth(int $depth): self {
$this->depth = max(5, min(50, $depth));
return $this;
}
public function render(string $format = 'png'): void {
$this->createCanvas();
$this->drawShadowBase(); // 1. 繪製底部陰影
$this->drawSideFaces(); // 2. 繪製側面(從後往前)
$this->drawTopFaces(); // 3. 繪製頂面
$this->drawLabels(); // 4. 繪製標籤
$this->output($format);
}
private function createCanvas(): void {
$this->image = imagecreatetruecolor($this->width, $this->height);
// 透明背景
$transparent = imagecolorallocatealpha($this->image, 255, 255, 255, 127);
imagefill($this->image, 0, 0, $transparent);
imagesavealpha($this->image, true);
}
private function hexToRgb(int $hex): array {
return [
($hex >> 16) & 0xFF,
($hex >> 8) & 0xFF,
$hex & 0xFF
];
}
private function allocateColor(int $hex, int $alpha = 0): int {
[$r, $g, $b] = $this->hexToRgb($hex);
return imagecolorallocatealpha($this->image, $r, $g, $b, $alpha);
}
private function darkerColor(int $hex, int $factor = 40): int {
[$r, $g, $b] = $this->hexToRgb($hex);
return $this->allocateColor(
(max(0, $r - $factor) << 16) |
(max(0, $g - $factor) << 8) |
max(0, $b - $factor)
);
}
// 橢圓上點的坐標
private function ellipsePoint(float $deg): array {
$rad = deg2rad($deg);
return [
$this->centerX + $this->radiusX * cos($rad),
$this->centerY + $this->radiusY * sin($rad)
];
}
// 繪製底部陰影(整個餅圖的投影)
private function drawShadowBase(): void {
$shadowColor = imagecolorallocatealpha($this->image, 0, 0, 0, 80);
for ($i = 1; $i <= $this->depth; $i++) {
imagefilledellipse(
$this->image,
$this->centerX,
$this->centerY + $i,
$this->radiusX * 2,
$this->radiusY * 2,
$shadowColor
);
}
}
// 繪製側面(關鍵:按角度從大到小排序,避免遮擋錯誤)
private function drawSideFaces(): void {
$sectors = [];
$startDeg = 0;
foreach ($this->data as $i => $value) {
$angle = ($value / $this->total) * 360;
$sectors[] = [
'index' => $i,
'start' => $startDeg,
'end' => $startDeg + $angle,
'mid' => $startDeg + $angle / 2
];
$startDeg += $angle;
}
// 後面的先畫:按中點角度排序(180° 附近最前)
usort($sectors, fn($a, $b) =>
abs($b['mid'] - 180) <=> abs($a['mid'] - 180)
);
foreach ($sectors as $sector) {
$this->drawSingleSide($sector);
}
}
private function drawSingleSide(array $sector): void {
$i = $sector['index'];
$start = $sector['start'];
$end = $sector['end'];
$color = $this->allocateColor($this->colors[$i]);
$darkColor = $this->darkerColor($this->colors[$i], 50);
// 側面由多邊形構成:頂面弧線 -> 下移 -> 底面弧線 -> 回頂面
$points = [];
// 頂面弧線取樣點
for ($deg = $start; $deg <= $end; $deg += 2) {
[$x, $y] = $this->ellipsePoint($deg);
$points[] = $x;
$points[] = $y;
}
// 底面弧線取樣點(反向)
for ($deg = $end; $deg >= $start; $deg -= 2) {
[$x, $y] = $this->ellipsePoint($deg);
$points[] = $x;
$points[] = $y + $this->depth;
}
if (count($points) >= 6) {
imagefilledpolygon($this->image, $points, count($points)/2, $darkColor);
}
// 繪製兩側邊緣線
[$x1, $y1] = $this->ellipsePoint($start);
[$x2, $y2] = $this->ellipsePoint($end);
imageline($this->image, $x1, $y1, $x1, $y1 + $this->depth, $darkColor);
imageline($this->image, $x2, $y2, $x2, $y2 + $this->depth, $darkColor);
}
// 繪製頂面扇形
private function drawTopFaces(): void {
$startDeg = 0;
foreach ($this->data as $i => $value) {
$angle = ($value / $this->total) * 360;
$endDeg = $startDeg + $angle;
$color = $this->allocateColor($this->colors[$i]);
// 使用 imagefilledarc 繪製頂面(GD 內置支持扇形填充)
imagefilledarc(
$this->image,
$this->centerX,
$this->centerY,
$this->radiusX * 2,
$this->radiusY * 2,
$startDeg,
$endDeg,
$color,
IMG_ARC_PIE | IMG_ARC_NOFILL // 只要邊緣?不,要填充
);
// 修正:IMG_ARC_PIE 已包含填充
imagefilledarc(
$this->image,
$this->centerX,
$this->centerY,
$this->radiusX * 2,
$this->radiusY * 2,
$startDeg,
$endDeg,
$color,
IMG_ARC_PIE
);
$startDeg = $endDeg;
}
}
// 繪製標籤與百分比
private function drawLabels(): void {
$startDeg = 0;
$font = __DIR__ . '/fonts/DejaVuSans.ttf'; // 需自備 TTF 字體
$fontSize = 12;
$labelColor = $this->allocateColor(0x333333);
foreach ($this->data as $i => $value) {
$angle = ($value / $this->total) * 360;
$midDeg = $startDeg + $angle / 2;
$percent = ($value / $this->total) * 100;
// 標籤位置:稍微超出橢圓外緣
$labelRadiusX = $this->radiusX * 1.2;
$labelRadiusY = $this->radiusY * 1.2;
$rad = deg2rad($midDeg);
$lx = $this->centerX + $labelRadiusX * cos($rad);
$ly = $this->centerY + $labelRadiusY * sin($rad);
$label = ($this->labels[$i] ?? '') . " " . number_format($percent, 1) . "%";
// 計算文本寬高居中
$box = imagettfbbox($fontSize, 0, $font, $label);
$tw = $box[2] - $box[0];
$th = $box[1] - $box[7];
imagettftext(
$this->image, $fontSize, 0,
$lx - $tw/2, $ly + $th/2,
$labelColor, $font, $label
);
$startDeg += $angle;
}
}
private function output(string $format): void {
header('Content-Type: image/' . ($format === 'jpg' ? 'jpeg' : $format));
match ($format) {
'png' => imagepng($this->image),
'jpg', 'jpeg' => imagejpeg($this->image, null, 90),
'gif' => imagegif($this->image),
default => imagepng($this->image)
};
imagedestroy($this->image);
}
}
<?php
require 'PieChart3D.php';
$data = [
'搜索引擎' => 4500,
'直接訪問' => 3200,
'外部鏈接' => 1800,
'社交媒體' => 1200,
'廣告推廣' => 800,
'其他' => 500
];
$chart = new PieChart3D(800, 500);
$chart->setData(
array_values($data),
array_keys($data),
[0xE74C3C, 0x3498DB, 0x2ECC71, 0xF39C12, 0x9B59B6, 0x95A5A6]
)
->setDepth(25)
->render('png');
imageantialias($img, true) 或超采樣(2x 畫布再縮放)| 方案 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|
| GD 手繪 | 零依賴、完全可控、無授權問題 | 代碼複雜、抗鋸齒弱、無交互 | 簡單報表、郵件嵌入、受限環境 |
| pChart | 功能豐富、支持漸變/陰影/圖例 | 維護停滯、PHP 8 兼容性差 | 老項目維護 |
| Chart.js (前端) | 交互豐富、動畫流暢、響應式 | 需 JS 環境、無法直接生成圖片 | 儀表板、用戶可見頁面 |
| QuickChart API | URL 生成圖表、支持 Chart.js 配置 | 外部依賴、頻率限制 | 快速集成、Serverless |
| Python Matplotlib | 專業級、出版質量、3D 支持強 | 需跨語言調用、部署複雜 | 數據科學報表、科研論文 |
GD 繪製 3D 餅圖的核心在於**繪製順序控制**(陰影→側面→頂面→標籤)和**橢圓幾何計算**。雖然代碼量較大,但零依賴、可完全定制,適合服務端生成靜態圖表場景。現代 Web 應用建議優先考慮前端圖表庫,服務端僅提供數據 API。