跳转到内容

模組:Graph/ChartRenderer

被永久保护的模块
维基百科,自由的百科全书
-- Module:Graph/ChartRenderer
-- 專門畫各種類型的圖表
local p = {}
local TrackingCategory = require('Module:TrackingCategory')
local Utils = require('Module:Graph/RenderUtils')

local format, sub, gsub, match, find = string.format, string.sub, string.gsub, string.match, string.find
local floor, ceil, abs, max, min = math.floor, math.ceil, math.abs, math.max, math.min
local sin, cos, sqrt, pi = math.sin, math.cos, math.sqrt, math.pi

local esc = Utils.esc
local formatNumber = Utils.formatNumber
local parseDateValue = Utils.parseDateValue
local formatDateValue = Utils.formatDateValue
local applyScale = Utils.applyScale
local computeNiceTicks = Utils.computeNiceTicks
local interpolateColorRamp = Utils.interpolateColorRamp
local renderSymbol = Utils.renderSymbol
local finalizeSvgWithOverlay = Utils.finalizeSvgWithOverlay
local buildPointHit = Utils.buildPointHit
local buildRectHit = Utils.buildRectHit
local FlushImgOverlays = Utils.FlushImgOverlays

-- 建構貼合扇形/環形扇區輪廓之 clip-path: polygon(...) 感應區 (供圓餅圖與極座標長條圖共用)
local function buildSectorHit(cx, cy, r0, r1, svgA0, svgA1, totalW, totalH, tipHtml, colorHex)
	if not tipHtml or tipHtml == "" then return "" end
	local rLo, rHi = min(r0, r1), max(r0, r1)
	local dAng = max(0.02, min(2 * pi, svgA1 - svgA0))
	local nSteps = max(4, min(24, ceil(dAng / 0.15)))
	local pts = {}
	local minPx, maxPx = math.huge, -math.huge
	local minPy, maxPy = math.huge, -math.huge

	local function addPt(px, py)
		pts[#pts + 1] = { px, py }
		if px < minPx then minPx = px end
		if px > maxPx then maxPx = px end
		if py < minPy then minPy = py end
		if py > maxPy then maxPy = py end
	end

	for s = 0, nSteps do
		local a = svgA0 + (s / nSteps) * dAng
		addPt(cx + rHi * cos(a), cy + rHi * sin(a))
	end
	if rLo > 0.5 then
		for s = nSteps, 0, -1 do
			local a = svgA0 + (s / nSteps) * dAng
			addPt(cx + rLo * cos(a), cy + rLo * sin(a))
		end
	elseif dAng < 2 * pi - 1e-3 then
		addPt(cx, cy)
	end

	minPx, maxPx = minPx - 1, maxPx + 1
	minPy, maxPy = minPy - 1, maxPy + 1
	local boxW = max(4, maxPx - minPx)
	local boxH = max(4, maxPy - minPy)
	local polyCoords = {}
	for k = 1, #pts do
		local lx = ((pts[k][1] - minPx) / boxW) * 100
		local ly = ((pts[k][2] - minPy) / boxH) * 100
		polyCoords[k] = format("%.1f%% %.1f%%", max(0, min(100, lx)), max(0, min(100, ly)))
	end

	local hitStr = buildRectHit(minPx, minPy, boxW, boxH, totalW, totalH, tipHtml, colorHex)
	local polyStr = table.concat(polyCoords, ", ")
	local polySpan = format(';pointer-events:none"><span class="gr-polar-poly" style="pointer-events:auto;-webkit-clip-path:polygon(%s);clip-path:polygon(%s)"></span>',
		polyStr, polyStr)
	hitStr = gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-polar-hit"', 1)
	-- 使用函數替換避免 Lua gsub 將百分比符號 '%' 當作跳脫字元剝除
	hitStr = gsub(hitStr, '">', function() return polySpan end, 1)
	return hitStr
end

-- 解析極座標起始角度與旋轉方向:
-- startAngle >= 0 (含 0, -0):從 (startAngle % 360) 度起算,逆時針旋轉 (svgDir = -1)
-- startAngle < 0 (如 -270, -360):從 (startAngle % 360) 度起算,順時針旋轉 (svgDir = 1)
local function resolvePolarTransform(spec, defaultStartDeg)
	local startDeg = tonumber(spec.startAngle)
	if startDeg == nil then startDeg = defaultStartDeg or 0 end
	local svgDir = (startDeg < 0) and 1 or -1
	local mathZeroRad = (startDeg % 360) * (pi / 180)
	local svgZero = -mathZeroRad
	local function toSvgAngle(radOffset)
		return svgZero + svgDir * radOffset
	end
	return svgZero, svgDir, toSvgAngle
end

-- 判斷極座標 X 軸單位:支援 "turn" (0~1 轉,含 % 與 ‰ 自動觸發)、"radian"、"degree" 與 "category"
local function resolvePolarXUnit(spec, allXNumeric, dataXMax, maxZWidth, forceContinuous)
	local xFmtLow = string.lower(tostring(spec.xAxisFormat or ""))
	local xTypeLow = string.lower(tostring(spec.xType or ""))
	if find(xFmtLow, "%", 1, true) or find(xFmtLow, "‰", 1, true)
		or find(xFmtLow, "turn", 1, true) or find(xTypeLow, "turn", 1, true) then
		return "turn"
	elseif find(xFmtLow, "rad", 1, true) or find(xTypeLow, "rad", 1, true) then
		return "radian"
	elseif find(xFmtLow, "deg", 1, true) or find(xFmtLow, "angle", 1, true) or find(xFmtLow, "°", 1, true)
		or find(xTypeLow, "deg", 1, true) or find(xTypeLow, "angle", 1, true) then
		return "degree"
	elseif allXNumeric and forceContinuous then
		if dataXMax > 7.0 or (maxZWidth and maxZWidth > 7.0) then
			return "degree"
		else
			return "radian"
		end
	end
	return "category"
end

-- 格式化極座標 8 等分放射軸刻度標籤 (k = 0..7)
local function formatPolarRadialTick(k, xUnit, xAxisFormat)
	if xUnit == "turn" then
		local turnVal = k / 8
		if xAxisFormat and (find(xAxisFormat, "%", 1, true) or find(xAxisFormat, "‰", 1, true)) then
			return formatNumber(turnVal, xAxisFormat, false)
		end
		return (k == 0) and "0" or (formatNumber(turnVal, nil, false) .. " turn")
	elseif xUnit == "radian" then
		local radLabels = { "0", "π/4", "π/2", "3π/4", "π", "5π/4", "3π/2", "7π/4" }
		return radLabels[k + 1]
	else
		return format("%d°", k * 45)
	end
end

-- 圓餅圖 / 環圈圖渲染器 (Pie / Donut Chart)
local function renderPieChart(spec)
	local plotW = max(100, spec.width or 200)
	local plotH = max(100, spec.height or 200)
	local grey = spec.persistentGrey or "#54595d"

	local xArr = spec.x or {}
	local yArr = (spec.y and spec.y[1]) or {}
	local nSlices = max(xArr.n or #xArr, yArr.n or #yArr)
	local hasPieCmap = spec.colorMap and Utils.isColormapName(spec.colorMap)

	local slices, total = {}, 0
	for i = 1, nSlices do
		local v = tonumber(yArr[i])
		if v and v > 0 then
			total = total + v
			local col = hasPieCmap
				and Utils.sampleColormap((i - 1) / max(1, nSlices - 1), spec.colorMap)
				or spec.colors[(i - 1) % #spec.colors + 1]
			slices[#slices + 1] = {
				idx = i,
				label = tostring(xArr[i] or ("項目" .. i)),
				val = v,
				color = col,
				alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 1
			}
		end
	end
	if total <= 0 then total = 1 end

	local legW = 0
	if spec.showLegend and #slices > 0 then
		for _, s in ipairs(slices) do
			legW = max(legW, mw.ustring.len(s.label) * 7.5 + 32)
		end
		legW = min(220, max(80, legW))
	end

	local padT = spec.title and 32 or 12
	local padB = 14
	local padL = 14
	local padR = 14 + legW
	local outerR = spec.outerRadius or (min(plotW, plotH) / 2 - 6)
	outerR = max(24, outerR)
	local innerR = max(0, min(outerR - 4, tonumber(spec.innerRadius) or 0))

	local totalW = padL + plotW + padR
	local totalH = max(padT + plotH + padB, padT + (#slices * 18 + 24) + padB)
	local cx = padL + plotW / 2
	local cy = padT + plotH / 2

	local buf = {}
	local textBuf = {}
	local tipBuf = {}
	local _, _, toSvgAngle = resolvePolarTransform(spec, -270)
	local curRad = 0

	for _, s in ipairs(slices) do
		local frac = s.val / total
		local dAng = frac * 2 * pi
		local nextRad = curRad + dAng
		local a0 = toSvgAngle(curRad)
		local a1 = toSvgAngle(nextRad)
		local svgA0, svgA1 = min(a0, a1), max(a0, a1)
		local midAng = (svgA0 + svgA1) * 0.5
		local opAttr = (s.alpha < 1) and format(' fill-opacity="%.2f"', s.alpha) or ""

		if frac >= 0.9999 then
			if innerR > 0 then
				local d = format("M %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f M %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f Z",
					cx, cy - outerR, outerR, outerR, cx, cy + outerR, outerR, outerR, cx, cy - outerR,
					cx, cy - innerR, innerR, innerR, cx, cy + innerR, innerR, innerR, cx, cy - innerR)
				buf[#buf + 1] = format('<path d="%s" fill="%s" fill-rule="evenodd" stroke="#ffffff" stroke-width="1"%s/>', d, s.color, opAttr)
			else
				buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"%s/>', cx, cy, outerR, s.color, opAttr)
			end
		else
			local largeArc = (dAng > pi) and 1 or 0
			local x1, y1 = cx + outerR * cos(svgA0), cy + outerR * sin(svgA0)
			local x2, y2 = cx + outerR * cos(svgA1), cy + outerR * sin(svgA1)
			if innerR > 0 then
				local ix1, iy1 = cx + innerR * cos(svgA1), cy + innerR * sin(svgA1)
				local ix2, iy2 = cx + innerR * cos(svgA0), cy + innerR * sin(svgA0)
				buf[#buf + 1] = format('<path d="M %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,0 %.2f,%.2f Z" fill="%s" stroke="#ffffff" stroke-width="1"%s/>',
					x1, y1, outerR, outerR, largeArc, x2, y2, ix1, iy1, innerR, innerR, largeArc, ix2, iy2, s.color, opAttr)
			else
				buf[#buf + 1] = format('<path d="M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f Z" fill="%s" stroke="#ffffff" stroke-width="1"%s/>',
					cx, cy, x1, y1, outerR, outerR, largeArc, x2, y2, s.color, opAttr)
			end
		end

		if spec.showValues and frac >= 0.035 then
			local sv = spec.showValues
			local offset = tonumber(sv.offset) or -18
			local fSize = tonumber(sv.fontsize) or 11
			local fColor = sv.fontcolor or (offset < 0 and "#ffffff" or grey)
			local lblR = (offset < 0) and max((innerR + outerR) / 2, outerR + offset) or (outerR + offset)
			local lx, ly = cx + lblR * cos(midAng), cy + lblR * sin(midAng)
			local txt = formatNumber(s.val, sv.format, spec.yType == "integer")
			local haloAttr = (offset >= 0) and ' data-halo="1"' or ''
			textBuf[#textBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" font-weight="bold" fill="%s"%s>%s</text>',
				lx, ly + 4, fSize, fColor, haloAttr, esc(txt))
		end

		if spec.interactive ~= false then
			local valStr = formatNumber(s.val, spec.yAxisFormat, spec.yType == "integer")
			local tipHtml = format('<b>%s</b>: %s (%.1f%%)', esc(s.label), esc(valStr), frac * 100)
			tipBuf[#tipBuf + 1] = buildSectorHit(cx, cy, innerR, outerR, svgA0, svgA1, totalW, totalH, tipHtml, s.color)
		end

		curRad = nextRad
	end

	for i = 1, #textBuf do buf[#buf + 1] = textBuf[i] end

	if spec.title then
		buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>',
			cx, grey, esc(spec.title))
	end

	if spec.showLegend and #slices > 0 then
		local legX = padL + plotW + 10
		local legY = padT + 4
		if spec.legendTitle then
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="12px" font-weight="bold" fill="%s">%s</text>',
				legX, legY + 10, grey, esc(spec.legendTitle))
			legY = legY + 20
		end
		for i, s in ipairs(slices) do
			local iy = legY + (i - 1) * 18
			buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s"/>', legX, iy, s.color)
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>',
				legX + 18, iy + 9.5, grey, esc(s.label))
		end
	end

	local animCls = (spec.animate ~= false) and "gr-anim gr-anim-radial" or nil
	TrackingCategory.append('有SVG圖表的頁面')
	return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end

-- 區間浮動長條圖渲染器 (採用 SVG 原生內嵌 <style> @keyframes 實現全歷史長卷軸與底部選擇框 100% 同步巡航)
local function renderRangedRectChart(spec)
	local rr = spec.rangedRect
	local recs = rr.records or {}
	local plotW = max(150, spec.width or 500)
	local mainH = max(80, spec.height or 200)
	local showOv = rr.showOverview
	local ovH = showOv and (rr.overviewHeight or 40) or 0
	local gapH = showOv and (rr.gapHeight or 26) or 0
	if showOv and mainH > (ovH + gapH + 60) then
		mainH = mainH - ovH - gapH
	end
	local grey = spec.persistentGrey or "#54595d"

	local items = {}
	local allXMin, allXMax = math.huge, -math.huge
	local allYMin, allYMax = math.huge, -math.huge

	local idx = 1
	while recs[idx] ~= nil do
		local r = recs[idx]
		local xv = parseDateValue(r[rr.xField] or r.date or r.x)
		local yLo = tonumber(r[rr.yLowField])
		local yHi = tonumber(r[rr.yHighField])
		local cVal = rr.colorField and tonumber(r[rr.colorField]) or ((yLo and yHi) and ((yLo + yHi) / 2) or 0)
		if xv and yLo and yHi then
			items[#items + 1] = { x = xv, yLo = min(yLo, yHi), yHi = max(yLo, yHi), c = cVal }
			if xv < allXMin then allXMin = xv end
			if xv > allXMax then allXMax = xv end
			if min(yLo, yHi) < allYMin then allYMin = min(yLo, yHi) end
			if max(yLo, yHi) > allYMax then allYMax = max(yLo, yHi) end
		end
		idx = idx + 1
	end

	if #items == 0 then
		allXMin, allXMax, allYMin, allYMax = 2000, 2020, 0, 30
	end

	local spanYears = rr.initYears or 7
	local totalSpan = max(1, allXMax - allXMin)
	local isPannable = (spec.animate ~= false) and (totalSpan > spanYears * 1.15)

	local viewXMax = rr.maxDate and parseDateValue(rr.maxDate) or allXMax
	local viewXMin = rr.minDate and parseDateValue(rr.minDate) or max(allXMin, viewXMax - spanYears)
	if viewXMax <= viewXMin then viewXMin = allXMin; viewXMax = allXMax end

	local yTicks, niceYMin, niceYMax = computeNiceTicks(allYMin, allYMax, max(4, floor(mainH / 34)), "linear", false)

	local padL = 46 + (spec.yTitle and 16 or 0)
	local padR = 20
	local padT = (spec.title and 30 or 14)
	local padB = 28 + (spec.xTitle and 16 or 0)
	local totalW = padL + plotW + padR
	local totalH = padT + mainH + (showOv and (gapH + ovH + 22) or 0) + padB

	local function mapMainY(v) return padT + mainH - ((v - niceYMin) / max(1e-6, niceYMax - niceYMin)) * mainH end

	local buf = {}
	local extraOvBuf = {}
	local rampDomain = rr.colorDomain or { -15, 0, 15, 30 }
	local rampRange = rr.colorRange or { "#2b83ba", "#abdda4", "#fdae61", "#d7191c" }
	local fillOp = rr.fillOpacity or 0.45
	local strokeW = rr.strokeWidth or 1.4

	local ratio = min(25.0, max(1.3, totalSpan / max(1, spanYears)))
	local trackW = plotW * ratio
	-- 降低總秒數:乘數由 9.5 改為 4.0,下限由 32 秒改為 12 秒,上限 120 秒
	local animDur = min(90, max(12, floor(ratio * 2.0)))

	-- 將長條圖資料分箱聚合為最多 100 個感應區,避免撐爆模板展開大小並確保懸停精準對齊

	-- 將長條圖資料分箱聚合為最多 100 個感應區,避免撐爆模板展開大小並確保懸停精準對齊
	local function buildBinnedHits(itemList, xMinVal, xMaxVal, xOffset, plotWidth, mapYFn, containerW, containerH)
		local hits = {}
		if spec.interactive == false or #itemList == 0 then return hits end
		local MAX_BINS = 100
		local uStr = rr.unitLabel or ""
		local span = max(1e-6, xMaxVal - xMinVal)
		local safeBottomY = containerH - 30

		if #itemList <= MAX_BINS then
			local bw = max(2.0, min(14, (plotWidth / max(12, #itemList)) * 0.72))
			for _, it in ipairs(itemList) do
				local px = xOffset + ((it.x - xMinVal) / span) * plotWidth
				local pyHi = mapYFn(it.yHi)
				local pyLo = mapYFn(it.yLo)
				local bh = max(2.0, pyLo - pyHi)
				if (pyHi / max(1, containerH)) < 0.32 and (pyHi + bh > safeBottomY) then
					bh = max(2.0, safeBottomY - pyHi)
				end
				local col = interpolateColorRamp(it.c, rampDomain, rampRange)
				local dStr = formatDateValue(it.x, "%Y-%m")
				local tipHtml = format('<b>%s</b>: %s ~ %s%s',
					esc(dStr), esc(formatNumber(it.yLo, nil, false)), esc(formatNumber(it.yHi, nil, false)), esc(uStr))
				hits[#hits + 1] = buildRectHit(px - bw / 2, pyHi, bw, bh, containerW, containerH, tipHtml, col)
			end
		else
			local bins = {}
			for _, it in ipairs(itemList) do
				local bIdx = max(1, min(MAX_BINS, floor(((it.x - xMinVal) / span) * MAX_BINS) + 1))
				local b = bins[bIdx]
				if not b then
					b = { minX = it.x, maxX = it.x, yLo = it.yLo, yHi = it.yHi, sumC = it.c, count = 1 }
					bins[bIdx] = b
				else
					if it.x < b.minX then b.minX = it.x end
					if it.x > b.maxX then b.maxX = it.x end
					if it.yLo < b.yLo then b.yLo = it.yLo end
					if it.yHi > b.yHi then b.yHi = it.yHi end
					b.sumC = b.sumC + it.c
					b.count = b.count + 1
				end
			end
			local binW = plotWidth / MAX_BINS
			for bIdx = 1, MAX_BINS do
				local b = bins[bIdx]
				if b then
					local bx = xOffset + (bIdx - 1) * binW
					local pyHi = mapYFn(b.yHi)
					local pyLo = mapYFn(b.yLo)
					local bh = max(4.0, pyLo - pyHi)
					if (pyHi / max(1, containerH)) < 0.32 and (pyHi + bh > safeBottomY) then
						bh = max(4.0, safeBottomY - pyHi)
					end
					local avgC = b.sumC / b.count
					local col = interpolateColorRamp(avgC, rampDomain, rampRange)
					local d0 = formatDateValue(b.minX, "%Y-%m")
					local d1 = formatDateValue(b.maxX, "%Y-%m")
					local dLbl = (d0 == d1) and d0 or (d0 .. " ~ " .. d1)
					local tipHtml = format('<b>%s</b>: %s ~ %s%s',
						esc(dLbl), esc(formatNumber(b.yLo, nil, false)), esc(formatNumber(b.yHi, nil, false)), esc(uStr))
					hits[#hits + 1] = buildRectHit(bx, pyHi, binW, bh, containerW, containerH, tipHtml, col)
				end
			end
		end
		return hits
	end

	if not isPannable then
		buf[#buf + 1] = format('<defs><clipPath id="gr-rr-clip"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath></defs>', padL, padT, plotW, mainH)
	end

	for _, tk in ipairs(yTicks) do
		local py = mapMainY(tk)
		buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#e1e3e5" stroke-width="1"/>', padL, py, padL + plotW, py)
		buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1"/>', padL - 5, py, padL, py, grey)
		buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="%s">%s</text>', padL - 8, py + 3.8, grey, esc(formatNumber(tk, spec.yAxisFormat, false)))
	end

	if isPannable then
		-- 生成第二個超長 SVG 與對應的 100 格聚合 Tooltip,放入 HTML 滾動軌道中以支援 :hover 同步暫停
		local trackWInt = max(1, ceil(trackW))
		local vpH = mainH + 22
		local vpHInt = max(1, ceil(vpH))
		local function mapPanX(v) return ((v - allXMin) / totalSpan) * trackWInt end
		local function mapTrackY(v) return mainH - ((v - niceYMin) / max(1e-6, niceYMax - niceYMin)) * mainH end

		local trackBuf = {}
		for _, tk in ipairs(yTicks) do
			local py = mapTrackY(tk)
			trackBuf[#trackBuf + 1] = format('<line x1="0" y1="%.2f" x2="%d" y2="%.2f" stroke="#e1e3e5" stroke-width="1"/>', py, trackWInt, py)
		end

		local yStep = (totalSpan > 80) and 2 or 1
		local firstTickY = ceil(allXMin)
		if yStep == 2 and (firstTickY % 2 ~= 0) then firstTickY = firstTickY + 1 end
		local lastTickY = floor(allXMax)

		for yr = firstTickY, lastTickY, yStep do
			local px = mapPanX(yr)
			trackBuf[#trackBuf + 1] = format('<line x1="%.1f" y1="0" x2="%.1f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, px, mainH)
			trackBuf[#trackBuf + 1] = format('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, mainH, px, mainH + 5, grey)
			trackBuf[#trackBuf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="%s">%d</text>',
				px, mainH + 15, grey, yr)
		end

		if niceYMin < 0 and niceYMax > 0 then
			local py0 = mapTrackY(0)
			trackBuf[#trackBuf + 1] = format('<line x1="0" y1="%.2f" x2="%d" y2="%.2f" stroke="#888888" stroke-width="1" stroke-dasharray="4,3"/>',
				py0, trackWInt, py0)
		end

		local barW = max(1.8, min(12, (trackWInt / max(12, #items)) * 0.72))
		for _, it in ipairs(items) do
			local px = mapPanX(it.x)
			local pyHi = mapTrackY(it.yHi)
			local pyLo = mapTrackY(it.yLo)
			local bh = max(1.5, pyLo - pyHi)
			local col = interpolateColorRamp(it.c, rampDomain, rampRange)
			trackBuf[#trackBuf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.1f"/>',
				px - barW / 2, pyHi, barW, bh, col, fillOp, col, strokeW)
		end
		trackBuf[#trackBuf + 1] = format('<line x1="0" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1.8"/>', mainH, trackWInt, mainH, grey)

		local trackImgHtml = mw.svg.new()
			:setAttribute('viewBox', format('0 0 %d %d', trackWInt, vpHInt))
			:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
			:setImgAttribute('width', tostring(trackWInt))
			:setImgAttribute('height', tostring(vpHInt))
			:setImgAttribute('class', 'graph-chart-svg')
			:setContent(table.concat(trackBuf))
			:toImage()

		local trackHits = buildBinnedHits(items, allXMin, allXMax, 0, trackWInt, mapTrackY, trackWInt, vpHInt)
		local vpLeftPct = (padL / totalW) * 100
		local vpTopPct = (padT / totalH) * 100
		local vpWPct = (plotW / totalW) * 100
		local vpHPct = (vpH / totalH) * 100
		local panMoverWPct = (ratio - 1) * 100
		local trackWPct = (ratio / (ratio - 1)) * 100

		extraOvBuf[#extraOvBuf + 1] = format(
			'<div class="gr-rr-vp" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%">' ..
			'<div class="gr-rr-pan-mover" style="width:%.2f%%;animation-duration:%ds;will-change:transform">' ..
			'<div class="gr-rr-track" style="width:%.2f%%">%s%s</div>' ..
			'</div></div>',
			vpLeftPct, vpTopPct, vpWPct, vpHPct,
			panMoverWPct, animDur, trackWPct,
			trackImgHtml, table.concat(trackHits)
		)
	else
		local function mapMainX(v) return padL + ((v - viewXMin) / max(1e-6, viewXMax - viewXMin)) * plotW end
		local xTicks = computeNiceTicks(viewXMin, viewXMax, max(4, floor(plotW / 80)), "linear", true)
		for _, tk in ipairs(xTicks) do
			if tk >= viewXMin - 1e-6 and tk <= viewXMax + 1e-6 then
				local px = mapMainX(tk)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + mainH)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, padT + mainH, px, padT + mainH + 5, grey)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', px, padT + mainH + 15, grey, esc(formatDateValue(tk, "%Y")))
			end
		end

		local barW = max(2.0, min(14, (plotW / max(12, (viewXMax - viewXMin) * 12)) * 0.72))
		buf[#buf + 1] = '<g clip-path="url(#gr-rr-clip)">'
		if niceYMin < 0 and niceYMax > 0 then
			local py0 = mapMainY(0)
			buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#888888" stroke-width="1" stroke-dasharray="4,3"/>', padL, py0, padL + plotW, py0)
		end
		local visItems = {}
		for _, it in ipairs(items) do
			if it.x >= viewXMin - 0.2 and it.x <= viewXMax + 0.2 then
				local px = mapMainX(it.x)
				local pyHi = mapMainY(it.yHi)
				local pyLo = mapMainY(it.yLo)
				local bh = max(1.5, pyLo - pyHi)
				local col = interpolateColorRamp(it.c, rampDomain, rampRange)
				buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.1f"/>',
					px - barW / 2, pyHi, barW, bh, col, fillOp, col, strokeW)
				visItems[#visItems + 1] = it
			end
		end
		buf[#buf + 1] = '</g>'
		local staticHits = buildBinnedHits(visItems, viewXMin, viewXMax, padL, plotW, mapMainY, totalW, totalH)
		for i = 1, #staticHits do extraOvBuf[#extraOvBuf + 1] = staticHits[i] end
	end

	buf[#buf + 1] = format('<polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="%s" stroke-width="1.8"/>', padL, padT, padL, padT + mainH, padL + plotW, padT + mainH, grey)

	if showOv then
		local ovTop = padT + mainH + gapH + 10
		local function mapOvX(v) return padL + ((v - allXMin) / totalSpan) * plotW end
		local function mapOvY(v) return ovTop + ovH - ((v - niceYMin) / max(1e-6, niceYMax - niceYMin)) * ovH end
		local ovBarW = max(1.0, (plotW / max(12, #items)) * 0.75)

		for _, it in ipairs(items) do
			local px = mapOvX(it.x)
			local pyHi, pyLo = mapOvY(it.yHi), mapOvY(it.yLo)
			local col = interpolateColorRamp(it.c, rampDomain, rampRange)
			buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" fill-opacity="0.65"/>',
				px - ovBarW / 2, pyHi, ovBarW, max(1, pyLo - pyHi), col)
		end

		if isPannable then
			local ovLeftPct = (padL / totalW) * 100
			local ovTopPct = (ovTop / totalH) * 100
			local ovWPct = (plotW / totalW) * 100
			local ovHPct = (ovH / totalH) * 100
			local winMoverWPct = ((ratio - 1) / ratio) * 100
			local winBoxWPct = (1 / (ratio - 1)) * 100
			extraOvBuf[#extraOvBuf + 1] = format(
				'<div class="gr-rr-ov-vp" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%;pointer-events:none">' ..
				'<div class="gr-rr-win-mover" style="width:%.2f%%;animation-duration:%ds;will-change:transform">' ..
				'<div class="gr-rr-win" style="width:%.2f%%"></div>' ..
				'</div></div>',
				ovLeftPct, ovTopPct, ovWPct, ovHPct,
				winMoverWPct, animDur, winBoxWPct
			)
		else
			local selX0, selX1 = mapOvX(viewXMin), mapOvX(viewXMax)
			buf[#buf + 1] = format('<rect x="%.2f" y="%.1f" width="%.2f" height="%.1f" fill="#333333" fill-opacity="0.12" stroke="#54595d" stroke-width="1"/>',
				selX0, ovTop, max(4, selX1 - selX0), ovH)
		end

		buf[#buf + 1] = format('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="1.2"/>', padL, ovTop + ovH, padL + plotW, ovTop + ovH, grey)

		local ovTicks = computeNiceTicks(allXMin, allXMax, max(4, floor(plotW / 90)), "linear", true)
		for _, tk in ipairs(ovTicks) do
			if tk >= allXMin - 1e-6 and tk <= allXMax + 1e-6 then
				local px = mapOvX(tk)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, ovTop + ovH, px, ovTop + ovH + 4, grey)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="10px" fill="%s">%s</text>', px, ovTop + ovH + 14, grey, esc(formatDateValue(tk, "%Y")))
			end
		end
		if rr.overviewXTitle then
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-size="11px" font-weight="bold" fill="%s">%s</text>',
				padL + plotW / 2, ovTop + ovH + 28, grey, esc(rr.overviewXTitle))
		end
	end

	if spec.title then
		buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>', padL + plotW / 2, grey, esc(spec.title))
	end
	if spec.yTitle then
		local yc = padT + mainH / 2
		buf[#buf + 1] = format('<text x="14" y="%.1f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s" transform="rotate(-90, 14, %.1f)">%s</text>',
			yc, grey, yc, esc(spec.yTitle))
	end
	if rr.unitLabel then
		buf[#buf + 1] = format('<text x="24" y="22" text-anchor="middle" font-size="13px" font-weight="bold" fill="%s">%s</text>', grey, esc(rr.unitLabel))
	end

	local animCls = (spec.animate ~= false) and "gr-anim gr-anim-fade" or nil
	TrackingCategory.append('有SVG圖表的頁面')
	return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(extraOvBuf), animCls)
end

-- 箱形圖渲染器 (Box Plot)
local function renderBoxPlotChart(spec)
	local plotW = max(80, spec.width or 300)
	local plotH = max(60, spec.height or 200)
	local grey = spec.persistentGrey or "#54595d"
	local boxes = {}
	local dataYMin, dataYMax = math.huge, -math.huge

	if spec.boxPlot and spec.boxPlot.records then
		local bp = spec.boxPlot
		local recs = bp.records
		local idx = 1
		while recs[idx] ~= nil do
			local r = recs[idx]
			local lbl = tostring(r[bp.xField] or idx)
			local vMin = tonumber(r[bp.minField] or r.min or r.low)
			local vQ1 = tonumber(r[bp.q1Field] or r.q1)
			local vQ3 = tonumber(r[bp.q3Field] or r.q3)
			local vMax = tonumber(r[bp.maxField] or r.max or r.high)
			local vMed = tonumber(r[bp.medianField] or r.median or r.mid) or ((vQ1 and vQ3) and ((vQ1 + vQ3) / 2) or nil)
			if vMin and vQ1 and vQ3 and vMax then
				boxes[#boxes + 1] = { label = lbl, min = vMin, q1 = vQ1, med = vMed, q3 = vQ3, max = vMax }
				if vMin < dataYMin then dataYMin = vMin end
				if vMax > dataYMax then dataYMax = vMax end
			end
			idx = idx + 1
		end
	else
		local xArr = spec.x or {}
		local y = spec.y or {}
		local nPts = xArr.n or #xArr
		for j = 1, nPts do
			local vMin = y[1] and tonumber(y[1][j])
			local vQ1 = y[2] and tonumber(y[2][j])
			local vMed = y[3] and tonumber(y[3][j])
			local vQ3 = y[4] and tonumber(y[4][j])
			local vMax = y[5] and tonumber(y[5][j])
			if vMin and vQ1 and vMed and vQ3 and vMax then
				boxes[#boxes + 1] = { label = tostring(xArr[j] or j), min = vMin, q1 = vQ1, med = vMed, q3 = vQ3, max = vMax }
				if vMin < dataYMin then dataYMin = vMin end
				if vMax > dataYMax then dataYMax = vMax end
			end
		end
	end

	if dataYMin == math.huge then dataYMin, dataYMax = 0, 10 end
	local isPctY = spec.yAxisFormat and (string.find(spec.yAxisFormat, "%", 1, true) ~= nil or string.find(spec.yAxisFormat, "‰", 1, true) ~= nil)
	local yTicks, niceYMin, niceYMax = computeNiceTicks(tonumber(spec.yMin) or dataYMin, tonumber(spec.yMax) or dataYMax, max(4, floor(plotH / 36)), "linear", false)
	local needPctBoundsY = isPctY and (dataYMin < -1e-9 or dataYMax > 1 + 1e-9 or niceYMin < -1e-9 or niceYMax > 1 + 1e-9)
	if needPctBoundsY then
		for _, bVal in ipairs({ 0, 1 }) do
			if bVal >= niceYMin - 1e-9 and bVal <= niceYMax + 1e-9 then
				local exists = false
				for _, tk in ipairs(yTicks) do
					if abs(tk - bVal) < 1e-6 then exists = true; break end
				end
				if not exists then yTicks[#yTicks + 1] = bVal end
			end
		end
		table.sort(yTicks)
	end
	local padL, padR, padT, padB = 46 + (spec.yTitle and 18 or 0), 18, (spec.title and 30 or 14), 30 + (spec.xTitle and 18 or 0)
	local totalW, totalH = padL + plotW + padR, padT + plotH + padB
	local nBoxes = max(1, #boxes)
	local function mapY(v) return padT + plotH - ((v - niceYMin) / (niceYMax - niceYMin)) * plotH end

	local buf = {}
	for _, tk in ipairs(yTicks) do
		local py = mapY(tk)
		if spec.yGrid ~= false then
			buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#e1e3e5" stroke-width="1"/>', padL, py, padL + plotW, py)
		end
		buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1"/>', padL - 5, py, padL, py, grey)
		buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="%s">%s</text>', padL - 8, py + 3.8, grey, esc(formatNumber(tk, spec.yAxisFormat, false)))
	end

	if needPctBoundsY then
		for _, bVal in ipairs({ 0, 1 }) do
			if bVal >= niceYMin - 1e-9 and bVal <= niceYMax + 1e-9 then
				local py = mapY(bVal)
				if abs(py - (padT + plotH)) > 1.5 then
					buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1.4" opacity="0.85"/>',
						padL, py, padL + plotW, py, grey)
					for j = 1, #boxes do
						local cx = padL + ((j - 0.5) / nBoxes) * plotW
						buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" opacity="0.85"/>',
							cx, py - 3.5, cx, py + 3.5, grey)
					end
				end
			end
		end
	end

	local boxW = min(44, (plotW / nBoxes) * 0.55)
	local tipBuf = {}
	for j = 1, #boxes do
		local b = boxes[j]
		local cx = padL + ((j - 0.5) / nBoxes) * plotW
		local pyMin, pyQ1, pyMed, pyQ3, pyMax = mapY(b.min), mapY(b.q1), mapY(b.med), mapY(b.q3), mapY(b.max)
		local col = spec.colors[(j - 1) % #spec.colors + 1]
		buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.5"/>', cx, pyMax, cx, pyMin, grey)
		buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.5"/>', cx - boxW * 0.3, pyMax, cx + boxW * 0.3, pyMax, grey)
		buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.5"/>', cx - boxW * 0.3, pyMin, cx + boxW * 0.3, pyMin, grey)
		buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="0.75" stroke="%s" stroke-width="1.5"/>',
			cx - boxW / 2, min(pyQ1, pyQ3), boxW, max(2, abs(pyQ1 - pyQ3)), col, col)
		buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#ffffff" stroke-width="2"/>', cx - boxW / 2, pyMed, cx + boxW / 2, pyMed)
		buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', cx, padT + plotH + 16, grey, esc(b.label))
		if spec.interactive ~= false then
			local fMed = formatNumber(b.med, spec.yAxisFormat, false)
			local fQ1 = formatNumber(b.q1, spec.yAxisFormat, false)
			local fQ3 = formatNumber(b.q3, spec.yAxisFormat, false)
			local fMin = formatNumber(b.min, spec.yAxisFormat, false)
			local fMax = formatNumber(b.max, spec.yAxisFormat, false)
			local tipHtml = format('<b>%s</b> | Med: %s (Q1: %s, Q3: %s, Min: %s, Max: %s)',
				esc(b.label), esc(fMed), esc(fQ1), esc(fQ3), esc(fMin), esc(fMax))
			tipBuf[#tipBuf + 1] = buildRectHit(cx - boxW / 2, min(pyMax, pyMin), boxW, max(4, abs(pyMin - pyMax)), totalW, totalH, tipHtml, col)
		end
	end

	buf[#buf + 1] = format('<polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="%s" stroke-width="1.8"/>', padL, padT, padL, padT + plotH, padL + plotW, padT + plotH, grey)
	if spec.title then
		buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>', padL + plotW / 2, grey, esc(spec.title))
	end
	local animCls = (spec.animate ~= false) and "gr-anim gr-anim-grow" or nil
	TrackingCategory.append('有SVG圖表的頁面')
	return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end

-- 甘特圖渲染器 (Gantt Chart)
local function renderGanttChart(spec)
	local plotW = max(100, spec.width or 400)
	local plotH = max(60, spec.height or 200)
	local grey = spec.persistentGrey or "#54595d"
	local tasks, yCats, yCatMap = {}, {}, {}
	local dataXMin, dataXMax = math.huge, -math.huge
	local isDate = (spec.xType == "date")

	local function parseXVal(v)
		if v == nil or v == false then return nil end
		local num = tonumber(v)
		if num then return num end
		local dv = parseDateValue(v)
		if dv then isDate = true; return dv end
		return nil
	end

	if spec.gantt and spec.gantt.records then
		local gt = spec.gantt
		local recs = gt.records
		local idx = 1
		while recs[idx] ~= nil do
			local r = recs[idx]
			local yLbl = tostring(r[gt.yField] or idx)
			local x0 = parseXVal(r[gt.xStartField])
			local x1 = parseXVal(r[gt.xEndField])
			if x0 and x1 then
				if not yCatMap[yLbl] then yCats[#yCats + 1] = yLbl; yCatMap[yLbl] = #yCats end
				tasks[#tasks + 1] = { yi = yCatMap[yLbl], x0 = min(x0, x1), x1 = max(x0, x1), group = gt.colorField and tostring(r[gt.colorField] or "") or yLbl }
				if min(x0, x1) < dataXMin then dataXMin = min(x0, x1) end
				if max(x0, x1) > dataXMax then dataXMax = max(x0, x1) end
			end
			idx = idx + 1
		end
	else
		local xArr = spec.x or {}
		local y1 = spec.y and spec.y[1] or {}
		local y2 = (spec.y and spec.y[2]) or spec.x2 or {}
		local nPts = xArr.n or #xArr
		for j = 1, nPts do
			local yLbl = tostring(xArr[j] or j)
			local x0, x1 = parseXVal(y1[j]), parseXVal(y2[j])
			if x0 and x1 then
				if not yCatMap[yLbl] then yCats[#yCats + 1] = yLbl; yCatMap[yLbl] = #yCats end
				tasks[#tasks + 1] = { yi = yCatMap[yLbl], x0 = min(x0, x1), x1 = max(x0, x1), group = tostring(j) }
				if min(x0, x1) < dataXMin then dataXMin = min(x0, x1) end
				if max(x0, x1) > dataXMax then dataXMax = max(x0, x1) end
			end
		end
	end

	if dataXMin == math.huge then dataXMin, dataXMax = 0, 10 end
	local xTicks, niceXMin, niceXMax = computeNiceTicks(dataXMin, dataXMax, max(4, floor(plotW / 65)), "linear", not isDate)
	if isDate then niceXMin, niceXMax = dataXMin, dataXMax end
	if niceXMax <= niceXMin then niceXMax = niceXMin + 1 end

	local maxYLen = 4
	for _, c in ipairs(yCats) do maxYLen = max(maxYLen, mw.ustring.len(c)) end
	local padL = max(54, min(160, maxYLen * 7.5 + 16))
	local padR, padT, padB = 18, (spec.title and 30 or 14), 28 + (spec.xTitle and 18 or 0)
	local totalW, totalH = padL + plotW + padR, padT + plotH + padB
	local nCats = max(1, #yCats)
	local bandH = plotH / nCats
	local barH = max(4, min(24, bandH * 0.65))
	local function mapX(v) return padL + ((v - niceXMin) / (niceXMax - niceXMin)) * plotW end

	local buf = {}
	for _, tk in ipairs(xTicks) do
		if tk >= niceXMin - 1e-9 and tk <= niceXMax + 1e-9 then
			local px = mapX(tk)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + plotH)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, padT + plotH, px, padT + plotH + 5, grey)
			local lbl = isDate and formatDateValue(tk, spec.xAxisFormat) or formatNumber(tk, spec.xAxisFormat, false)
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', px, padT + plotH + 16, grey, esc(lbl))
		end
	end

	for i = 1, #yCats do
		local cy = padT + (i - 0.5) * bandH
		buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="%s">%s</text>', padL - 8, cy + 3.8, grey, esc(yCats[i]))
	end

	local tipBuf = {}
	for _, t in ipairs(tasks) do
		local cy = padT + (t.yi - 0.5) * bandH
		local px0, px1 = mapX(t.x0), mapX(t.x1)
		local bw = max(2, px1 - px0)
		local col = spec.colors[(t.yi - 1) % #spec.colors + 1]
		buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="2" fill="%s" fill-opacity="0.88"/>',
			px0, cy - barH / 2, bw, barH, col)
		if spec.interactive ~= false and #tipBuf < 200 then
			local s0 = isDate and formatDateValue(t.x0, spec.xAxisFormat) or formatNumber(t.x0, spec.xAxisFormat, false)
			local s1 = isDate and formatDateValue(t.x1, spec.xAxisFormat) or formatNumber(t.x1, spec.xAxisFormat, false)
			local tipHtml = format('<b>%s</b>: %s ~ %s', esc(yCats[t.yi] or ""), esc(s0), esc(s1))
			tipBuf[#tipBuf + 1] = buildRectHit(px0, cy - barH / 2, bw, barH, totalW, totalH, tipHtml, col)
		end
	end

	buf[#buf + 1] = format('<polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="%s" stroke-width="1.8"/>', padL, padT, padL, padT + plotH, padL + plotW, padT + plotH, grey)
	if spec.title then
		buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>', padL + plotW / 2, grey, esc(spec.title))
	end
	local animCls = (spec.animate ~= false) and "gr-anim gr-anim-fade" or nil
	TrackingCategory.append('有SVG圖表的頁面')
	return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end

-- 二維矩陣熱圖與像素方格圖渲染器 (Correlation Heatmap / QR Code / Pixel Art)
local function renderHeatmapChart(spec)
	local plotW = max(80, spec.width or 300)
	local plotH = max(80, spec.height or 260)
	local grey = spec.persistentGrey or "#54595d"
	local hm = spec.heatmap or {}

	-- 自動判定:若明確標記為 isPixelMode,或同時關閉座標軸與圖例,即進入 QR Code / 像素畫無框模式
	local isPixelMode = (hm.isPixelMode == true) or (spec.hideAxes and not spec.showLegend)
	local qrTooltip = hm.qrTooltip
	local scaleColorMap = hm.scaleColorMap

	local xCats, xMap, yCats, yMap, cells = {}, {}, {}, {}, {}
	local allXNumeric, allYNumeric = true, true
	local cMin, cMax = math.huge, -math.huge
	local hasNumericColor = false

	-- 相容讀取陣列索引 (將 Vega 傳來的字串 "0", "1" 轉為 Lua 的 1-based 陣列)
	local function getVal(r, f)
		if r[f] ~= nil then return r[f] end
		local n = tonumber(f)
		if n ~= nil then
			if r[1] ~= nil and r[0] == nil then return r[n + 1] end
			return r[n]
		end
		return nil
	end

	if hm.records then
		local recs = hm.records
		local xField = hm.xField or "x"
		local yField = hm.yField or "y"
		local cField = hm.colorField or "c"
		local idx = 1
		while recs[idx] ~= nil do
			local r = recs[idx]
			local xv, yv = getVal(r, xField), getVal(r, yField)
			local rawC = cField and getVal(r, cField) or nil
			if xv ~= nil and yv ~= nil then
				local xs, ys = tostring(xv), tostring(yv)
				if not xMap[xs] then
					xCats[#xCats + 1] = xs
					xMap[xs] = true
					if tonumber(xs) == nil then allXNumeric = false end
				end
				if not yMap[ys] then
					yCats[#yCats + 1] = ys
					yMap[ys] = true
					if tonumber(ys) == nil then allYNumeric = false end
				end
				local numC = tonumber(rawC)
				if numC ~= nil then
					hasNumericColor = true
					if numC < cMin then cMin = numC end
					if numC > cMax then cMax = numC end
				end
				cells[#cells + 1] = { xs = xs, ys = ys, val = numC, rawC = rawC }
			end
			idx = idx + 1
		end
	else
		local xArr = spec.x or {}
		local ySeries = spec.y or {}
		for j = 1, (xArr.n or #xArr) do
			local xs = tostring(xArr[j] or (j - 1))
			xCats[j] = xs
			xMap[xs] = j
			if tonumber(xs) == nil then allXNumeric = false end
		end
		for i = 1, #ySeries do
			local rawTitle = spec.seriesTitles and spec.seriesTitles[i]
			local ys = (rawTitle and not match(rawTitle, "^y%d+$")) and tostring(rawTitle) or tostring(i - 1)
			yCats[i] = ys
			yMap[ys] = i
			if tonumber(ys) == nil then allYNumeric = false end
			for j = 1, #xCats do
				local rawC = ySeries[i][j]
				local cv = tonumber(rawC)
				if cv ~= nil then
					hasNumericColor = true
					if cv < cMin then cMin = cv end
					if cv > cMax then cMax = cv end
					cells[#cells + 1] = { xs = xCats[j], ys = ys, val = cv, rawC = rawC }
				end
			end
		end
	end

	-- 排序數值座標軸並建立索引映射
	if allXNumeric and #xCats > 1 then
		table.sort(xCats, function(a, b)
			if hm.xReverse then return tonumber(a) > tonumber(b) end
			return tonumber(a) < tonumber(b)
		end)
	end
	if allYNumeric and #yCats > 1 then
		table.sort(yCats, function(a, b)
			if hm.yReverse then return tonumber(a) > tonumber(b) end
			return tonumber(a) < tonumber(b)
		end)
	end

	local numXMin, numXMax = 0, max(1, #xCats - 1)
	local numYMin, numYMax = 0, max(1, #yCats - 1)
	local nx, ny = max(1, #xCats), max(1, #yCats)

	if allXNumeric and #xCats > 0 then
		numXMin, numXMax = tonumber(xCats[1]) or 0, tonumber(xCats[#xCats]) or 0
		if hm.xReverse then numXMin, numXMax = numXMax, numXMin end
		local spanX = floor(numXMax - numXMin + 0.5) + 1
		if isPixelMode and spanX >= #xCats and spanX <= 200 then
			nx = spanX
		end
	end
	if allYNumeric and #yCats > 0 then
		numYMin, numYMax = tonumber(yCats[1]) or 0, tonumber(yCats[#yCats]) or 0
		if hm.yReverse then numYMin, numYMax = numYMax, numYMin end
		local spanY = floor(numYMax - numYMin + 0.5) + 1
		if isPixelMode and spanY >= #yCats and spanY <= 200 then
			ny = spanY
		end
	end

	for i = 1, #xCats do xMap[xCats[i]] = i end
	for i = 1, #yCats do yMap[yCats[i]] = i end

	for _, cell in ipairs(cells) do
		if isPixelMode and allXNumeric and nx > #xCats then
			cell.xi = floor((tonumber(cell.xs) or 0) - numXMin + 0.5) + 1
		else
			cell.xi = xMap[cell.xs] or 1
		end
		if isPixelMode and allYNumeric and ny > #yCats then
			cell.yi = floor((tonumber(cell.ys) or 0) - numYMin + 0.5) + 1
		else
			cell.yi = yMap[cell.ys] or 1
		end
	end

	if hm.domainMin ~= nil then cMin = tonumber(hm.domainMin) or cMin end
	if hm.domainMax ~= nil then cMax = tonumber(hm.domainMax) or cMax end
	if spec.zMin ~= nil then cMin = tonumber(spec.zMin) or cMin end
	if spec.zMax ~= nil then cMax = tonumber(spec.zMax) or cMax end
	if cMin == math.huge then cMin, cMax = 0, 1 end
	if cMax <= cMin then cMax = cMin + 1 end

	local cmapKey = spec.colorMap and string.lower(mw.text.trim(tostring(spec.colorMap))) or nil
	local rampRange = hm.colorRange
	if not rampRange then
		if cmapKey and Utils.isColormapName(cmapKey) then
			rampRange = {}
			for s = 0, 10 do
				rampRange[#rampRange + 1] = Utils.sampleColormap(s / 10, cmapKey)
			end
		else
			rampRange = { "#3b4cc0", "#7b9ff9", "#dddddd", "#f29472", "#b40426" }
		end
	end
	local rampDomain = hm.colorDomain
	if not rampDomain or #rampDomain < 2 then
		rampDomain = {}
		local nStops = #rampRange
		for s = 1, nStops do
			rampDomain[s] = cMin + ((s - 1) / max(1, nStops - 1)) * (cMax - cMin)
		end
	else
		cMin = tonumber(rampDomain[1]) or cMin
		cMax = tonumber(rampDomain[#rampDomain]) or cMax
	end

	local function resolveCellColor(cell)
		local rawKey = cell.rawC ~= nil and tostring(cell.rawC) or nil
		if isPixelMode and scaleColorMap and rawKey and scaleColorMap[rawKey] then
			return scaleColorMap[rawKey]
		end
		if not isPixelMode and hasNumericColor and cell.val ~= nil then
			return interpolateColorRamp(cell.val, rampDomain, rampRange)
		end
		if scaleColorMap and rawKey and scaleColorMap[rawKey] then
			return scaleColorMap[rawKey]
		end
		if cell.val ~= nil then
			return interpolateColorRamp(cell.val, rampDomain, rampRange)
		end
		if type(cell.rawC) == "string" and cell.rawC ~= "" then
			return cell.rawC
		end
		return hm.fixedColor or "#000000"
	end

	local buf = {}
	local tipBuf = {}

	-- 共用熱圖 Tooltip 生成器:<= 100 格使用原始值;> 100 格以 10x10 大網格面積佔比計算加權平均數並渲染加權平均色
	local function buildHeatmapHits(originX, originY, areaW, areaH, totalW, totalH)
		local hits = {}
		if spec.interactive == false or #cells == 0 then return hits end
		local totalCells = max(#cells, nx * ny)
		local cw = areaW / nx
		local ch = areaH / ny

		if totalCells <= 100 then
			for _, cell in ipairs(cells) do
				local tipHtml
				if qrTooltip and qrTooltip ~= "" then
					tipHtml = esc(qrTooltip)
				elseif cell.val ~= nil then
					tipHtml = format('<b>(%s, %s)</b>: %s', esc(cell.xs), esc(cell.ys), esc(formatNumber(cell.val, nil, false)))
				else
					tipHtml = format('<b>(%s, %s)</b>: %s', esc(cell.xs), esc(cell.ys), esc(tostring(cell.rawC or "")))
				end
				local px = originX + (cell.xi - 1) * cw
				local py = originY + (cell._rowIdx - 1) * ch
				hits[#hits + 1] = buildRectHit(px, py, cw, ch, totalW, totalH, tipHtml, cell._col)
			end
			return hits
		end

		local macroNX = min(10, nx)
		local macroNY = min(10, ny)
		local macroW = areaW / macroNX
		local macroH = areaH / macroNY
		local cellGrid = {}
		local bins = {}
		for cIdx = 1, macroNX do
			bins[cIdx] = {}
			for rIdx = 1, macroNY do
				bins[cIdx][rIdx] = { wSum = 0, valWSum = 0, valCount = 0, fallback = nil }
			end
		end

		for _, cell in ipairs(cells) do
			local xi = cell.xi
			local rIdxOrig = cell._rowIdx
			if not cellGrid[xi] then cellGrid[xi] = {} end
			cellGrid[xi][rIdxOrig] = cell

			-- 計算原始格在 10x10 大網格中的交集面積佔比作為權重
			local x0 = ((xi - 1) / nx) * macroNX
			local x1 = (xi / nx) * macroNX
			local y0 = ((rIdxOrig - 1) / ny) * macroNY
			local y1 = (rIdxOrig / ny) * macroNY

			local cStart = max(1, floor(x0 + 1e-9) + 1)
			local cEnd = min(macroNX, ceil(x1 - 1e-9))
			local rStart = max(1, floor(y0 + 1e-9) + 1)
			local rEnd = min(macroNY, ceil(y1 - 1e-9))

			for c = cStart, cEnd do
				local ow = min(x1, c) - max(x0, c - 1)
				if ow > 1e-9 then
					for r = rStart, rEnd do
						local oh = min(y1, r) - max(y0, r - 1)
						if oh > 1e-9 then
							local b = bins[c][r]
							b.fallback = cell
							if cell.val ~= nil then
								local w = ow * oh
								b.wSum = b.wSum + w
								b.valWSum = b.valWSum + cell.val * w
								b.valCount = b.valCount + 1
							end
						end
					end
				end
			end
		end

		for rIdx = 1, macroNY do
			local tRow = max(1, min(ny, floor((rIdx - 0.5) * ny / macroNY) + 1))
			for cIdx = 1, macroNX do
				local b = bins[cIdx][rIdx]
				local tXi = max(1, min(nx, floor((cIdx - 0.5) * nx / macroNX) + 1))
				local rep = (cellGrid[tXi] and cellGrid[tXi][tRow]) or b.fallback
				if rep then
					local tipHtml, macroCol
					if qrTooltip and qrTooltip ~= "" then
						tipHtml = esc(qrTooltip)
						macroCol = rep._col
					elseif b.wSum > 0 then
						local avgVal = b.valWSum / b.wSum
						if isPixelMode and scaleColorMap and not hm.colorRange then
							local k0, k1 = tostring(floor(avgVal)), tostring(ceil(avgVal))
							if scaleColorMap[k0] and scaleColorMap[k1] then
								macroCol = interpolateColorRamp(avgVal, { floor(avgVal), ceil(avgVal) }, { scaleColorMap[k0], scaleColorMap[k1] })
							else
								macroCol = scaleColorMap[tostring(floor(avgVal + 0.5))] or rep._col
							end
						else
							macroCol = interpolateColorRamp(avgVal, rampDomain, rampRange)
						end
						local vStr = formatNumber(avgVal, nil, false)
						local prefix = (b.valCount > 1) and "約" or ""
						tipHtml = format('<b>(%s, %s)</b>: %s%s', esc(rep.xs), esc(rep.ys), prefix, esc(vStr))
					elseif rep.val ~= nil then
						macroCol = rep._col
						tipHtml = format('<b>(%s, %s)</b>: %s', esc(rep.xs), esc(rep.ys), esc(formatNumber(rep.val, nil, false)))
					else
						macroCol = rep._col
						tipHtml = format('<b>(%s, %s)</b>: %s', esc(rep.xs), esc(rep.ys), esc(tostring(rep.rawC or "")))
					end

					local px = originX + (cIdx - 1) * macroW
					local py = originY + (rIdx - 1) * macroH
					local hitStr = buildRectHit(px, py, macroW, macroH, totalW, totalH, tipHtml, macroCol)
					local rgb = Utils.parseRGB(macroCol)
					local bgStyle = format(';background-color:rgba(%d,%d,%d,0.78)">', rgb[1], rgb[2], rgb[3])
					hitStr = string.gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-hm-macro"', 1)
					hitStr = string.gsub(hitStr, '">', bgStyle, 1)
					hits[#hits + 1] = hitStr
				end
			end
		end
		return hits
	end

	-- ========================================================================
	-- 模式 A:QR Code / 像素畫模式 (關閉座標軸與圖例時,無縫滿版方格 + 支援 datas 逐格 Tooltip)
	-- ========================================================================
	if isPixelMode then
		local qz = max(8, floor(min(plotW, plotH) * 0.05))
		local padL, padR = qz, qz
		local padT = qz + (spec.title and 24 or 0)
		local padB = qz
		local totalW = padL + plotW + padR
		local totalH = padT + plotH + padB
		local cellW = plotW / nx
		local cellH = plotH / ny

		local bgCol = "#ffffff"
		if scaleColorMap and scaleColorMap["0"] then
			bgCol = scaleColorMap["0"]
		end
		buf[#buf + 1] = format('<rect x="0" y="0" width="%d" height="%d" fill="%s"/>', ceil(totalW), ceil(totalH), bgCol)
		buf[#buf + 1] = '<g shape-rendering="crispEdges">'

		local function isBgColor(c)
			local lc = string.lower(tostring(c or ""))
			return lc == string.lower(bgCol) or lc == "#fff" or lc == "#ffffff" or lc == "white"
		end

		local fgBuf = {}
		for _, cell in ipairs(cells) do
			local col = resolveCellColor(cell)
			local px = padL + (cell.xi - 1) * cellW
			local rowIdx = (allYNumeric and not hm.yReverse) and (ny - cell.yi + 1) or cell.yi
			local py = padT + (rowIdx - 1) * cellH

			cell._col = col
			cell._rowIdx = rowIdx

			if isBgColor(col) then
				buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>',
					px, py, cellW, cellH, col)
			else
				fgBuf[#fgBuf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="0.4"/>',
					px, py, cellW, cellH, col, col)
			end
		end
		for i = 1, #fgBuf do buf[#buf + 1] = fgBuf[i] end
		buf[#buf + 1] = '</g>'

		-- QR Code 維持 > 64 格降級為 8x8 並顯示全局外框;一般方格圖則呼叫 buildHeatmapHits
		if qrTooltip and qrTooltip ~= "" then
			local tipHtml = esc(qrTooltip)
			local totalCells = nx * ny
			if totalCells > 64 then
				local macroN = 8
				local macroW = plotW / macroN
				local macroH = plotH / macroN
				for rIdx = 1, macroN do
					for cIdx = 1, macroN do
						local px = padL + (cIdx - 1) * macroW
						local py = padT + (rIdx - 1) * macroH
						local hitStr = buildRectHit(px, py, macroW, macroH, totalW, totalH, tipHtml, nil)
						hitStr = string.gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-macro-hit"')
						tipBuf[#tipBuf + 1] = hitStr
					end
				end
			else
				for rIdx = 1, ny do
					for cIdx = 1, nx do
						local px = padL + (cIdx - 1) * cellW
						local py = padT + (rIdx - 1) * cellH
						local hitStr = buildRectHit(px, py, cellW, cellH, totalW, totalH, tipHtml, nil)
						hitStr = string.gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-macro-hit"')
						tipBuf[#tipBuf + 1] = hitStr
					end
				end
			end

			local frameLeftPct = (padL / totalW) * 100
			local frameTopPct = (padT / totalH) * 100
			local frameWPct = (plotW / totalW) * 100
			local frameHPct = (plotH / totalH) * 100
			tipBuf[#tipBuf + 1] = format('<span class="gr-qr-frame" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%;pointer-events: none;"></span>',
				frameLeftPct, frameTopPct, frameWPct, frameHPct)
		else
			tipBuf = buildHeatmapHits(padL, padT, plotW, plotH, totalW, totalH)
		end

		if spec.title then
			buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>',
				padL + plotW / 2, grey, esc(spec.title))
		end

		TrackingCategory.append('有SVG圖表的頁面')
		return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), nil)
	end

	-- ========================================================================
	-- 模式 B:相關性熱圖模式 (四邊封閉外框、內縮留白、右側垂直漸層圖例、單格或加權平均 Tooltip)
	-- ========================================================================
	local showColorbar = (spec.showLegend ~= false)
	local maxYLen = 4
	if not allYNumeric then
		for _, c in ipairs(yCats) do maxYLen = max(maxYLen, mw.ustring.len(c)) end
	end
	local padL = (not spec.hideAxes) and (max(46, min(140, maxYLen * 7.5 + 16)) + (spec.yTitle and 18 or 0)) or 14
	local cbGap = 12
	local cbW = 14
	local cbLabelW = 36
	local padR = showColorbar and (cbGap + cbW + cbLabelW) or 18
	local padT = (spec.title and 32 or 14)
	local padB = (not spec.hideAxes) and (28 + (spec.xTitle and 18 or 0)) or 14
	local totalW = padL + plotW + padR
	local totalH = padT + plotH + padB

	if showColorbar then
		local stopsBuf = { '<defs><linearGradient id="gr-hm-grad" x1="0%" y1="100%" x2="0%" y2="0%">' }
		local nGradStops = 10
		for s = 0, nGradStops do
			local t = s / nGradStops
			local v = cMin + t * (cMax - cMin)
			local col = interpolateColorRamp(v, rampDomain, rampRange)
			stopsBuf[#stopsBuf + 1] = format('<stop offset="%d%%" stop-color="%s"/>', floor(t * 100 + 0.5), col)
		end
		stopsBuf[#stopsBuf + 1] = '</linearGradient></defs>'
		buf[#buf + 1] = table.concat(stopsBuf)
	end

	local marginFracX = 0.15 / (nx + 0.30)
	local marginFracY = 0.18 / (ny + 0.36)
	local gridLeft = padL + plotW * marginFracX
	local gridTop = padT + plotH * marginFracY
	local gridW = plotW * (1 - 2 * marginFracX)
	local gridH = plotH * (1 - 2 * marginFracY)
	local cellW = gridW / nx
	local cellH = gridH / ny

	buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#f9f9fb"/>', padL, padT, plotW, plotH)
	buf[#buf + 1] = '<g shape-rendering="crispEdges">'

	for _, cell in ipairs(cells) do
		local col = resolveCellColor(cell)
		local px = gridLeft + (cell.xi - 1) * cellW
		local rowIdx = (allYNumeric and not hm.yReverse) and (ny - cell.yi + 1) or cell.yi
		local py = gridTop + (rowIdx - 1) * cellH

		cell._col = col
		cell._rowIdx = rowIdx

		buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="0.4"/>',
			px, py, cellW, cellH, col, col)
	end
	buf[#buf + 1] = '</g>'

	tipBuf = buildHeatmapHits(gridLeft, gridTop, gridW, gridH, totalW, totalH)

	if not spec.hideAxes then
		buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="none" stroke="#222222" stroke-width="1.3"/>',
			padL, padT, plotW, plotH)

		if allXNumeric then
			local stepX = (nx > 1) and ((numXMax - numXMin) / (nx - 1)) or 1
			if stepX == 0 then stepX = 1 end
			local boxXMin = numXMin - 0.65 * stepX
			local boxXMax = numXMax + 0.65 * stepX
			local nIntervals = 6
			for k = 0, nIntervals do
				local frac = k / nIntervals
				local px = padL + frac * plotW
				local val = boxXMin + frac * (boxXMax - boxXMin)
				if abs(val) < 1e-9 then val = 0 end
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#222222" stroke-width="1.2"/>',
					px, padT + plotH, px, padT + plotH + 5)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="#222222">%s</text>',
					px, padT + plotH + 16, esc(formatNumber(val, spec.xAxisFormat, false)))
			end
		else
			for j = 1, #xCats do
				local cx = gridLeft + (j - 0.5) * cellW
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#222222" stroke-width="1.2"/>',
					cx, padT + plotH, cx, padT + plotH + 5)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="#222222">%s</text>',
					cx, padT + plotH + 16, esc(xCats[j]))
			end
		end

		if allYNumeric then
			local stepY = (ny > 1) and ((numYMax - numYMin) / (ny - 1)) or 1
			if stepY == 0 then stepY = 1 end
			local boxYMin = numYMin - 0.68 * stepY
			local boxYMax = numYMax + 0.68 * stepY
			local nIntervals = 6
			for k = 0, nIntervals do
				local frac = k / nIntervals
				local py = padT + plotH - frac * plotH
				local val = boxYMin + frac * (boxYMax - boxYMin)
				if abs(val) < 1e-9 then val = 0 end
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#222222" stroke-width="1.2"/>',
					padL - 5, py, padL, py)
				buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="#222222">%s</text>',
					padL - 8, py + 3.8, esc(formatNumber(val, spec.yAxisFormat, false)))
			end
		else
			for i = 1, #yCats do
				local cy = gridTop + (i - 0.5) * cellH
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#222222" stroke-width="1.2"/>',
					padL - 5, cy, padL, cy)
				buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="#222222">%s</text>',
					padL - 8, cy + 3.8, esc(yCats[i]))
			end
		end
	end

	if showColorbar then
		local cbX = padL + plotW + cbGap
		local cbY = padT
		local cbH = plotH
		buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="url(#gr-hm-grad)" stroke="#222222" stroke-width="1.2"/>',
			cbX, cbY, cbW, cbH)

		local nCbIntervals = 4
		for k = 0, nCbIntervals do
			local frac = k / nCbIntervals
			local py = cbY + cbH - frac * cbH
			local val = cMin + frac * (cMax - cMin)
			if abs(val) < 1e-9 then val = 0 end
			buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#222222" stroke-width="1.2"/>',
				cbX + cbW, py, cbX + cbW + 4, py)
			buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" font-size="11px" fill="#222222">%s</text>',
				cbX + cbW + 7, py + 3.8, esc(formatNumber(val, nil, false)))
		end
	end

	if spec.title then
		buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="#222222">%s</text>',
			padL + plotW / 2, esc(spec.title))
	end
	if spec.xTitle and not spec.hideAxes then
		buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
			padL + plotW / 2, padT + plotH + padB - 6, grey, esc(spec.xTitle))
	end
	if spec.yTitle and not spec.hideAxes then
		local yc = padT + plotH / 2
		buf[#buf + 1] = format('<text x="14" y="%.1f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s" transform="rotate(-90, 14, %.1f)">%s</text>',
			yc, grey, yc, esc(spec.yTitle))
	end

	local animCls = (spec.animate ~= false) and "gr-anim gr-anim-fade" or nil
	TrackingCategory.append('有SVG圖表的頁面')
	return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end

-- 雷達圖 / 極座標折線圖 / 極座標面積圖渲染器 (Radar / Polar Line / Polar Area Chart)
local function renderRadarChart(spec)
	local plotW = max(140, spec.width or 260)
	local plotH = max(140, spec.height or 260)
	local grey = spec.persistentGrey or "#54595d"

	local xArr = spec.x or {}
	local ySeries = spec.y or {}
	local nSeries = #ySeries
	local nAxes = xArr.n or #xArr
	for i = 1, nSeries do
		local len = ySeries[i].n or #ySeries[i]
		if len > nAxes then nAxes = len end
	end
	nAxes = max(3, nAxes)

	local allXNumeric = true
	local xNums = {}
	local dataXMax = -math.huge
	for j = 1, nAxes do
		local xv = tonumber(xArr[j])
		if xv == nil and xArr[j] ~= nil and xArr[j] ~= false then
			allXNumeric = false
		end
		xNums[j] = xv
		if xv and xv > dataXMax then dataXMax = xv end
	end

	local xUnit = resolvePolarXUnit(spec, allXNumeric, dataXMax, 0, spec.isPolarLine)
	local isContinuousPolar = (xUnit == "turn" or xUnit == "radian" or xUnit == "degree")
	local defaultStartDeg = (isContinuousPolar or spec.isPolarLine) and 0 or -270
	local _, _, toSvgAngle = resolvePolarTransform(spec, defaultStartDeg)

	local dataYMin, dataYMax = 0, -math.huge
	for i = 1, nSeries do
		for j = 1, nAxes do
			local v = ySeries[i] and tonumber(ySeries[i][j])
			if v then
				if v < dataYMin then dataYMin = v end
				if v > dataYMax then dataYMax = v end
			end
		end
	end
	if dataYMax == -math.huge then dataYMin, dataYMax = 0, 100 end
	if dataYMin == dataYMax then dataYMax = dataYMin + 10 end

	local yMin = tonumber(spec.yMin) or min(0, dataYMin)
	local yMax = tonumber(spec.yMax) or dataYMax
	local isPctY = spec.yAxisFormat and (find(spec.yAxisFormat, "%", 1, true) ~= nil or find(spec.yAxisFormat, "‰", 1, true) ~= nil)
	local yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, 4, spec.yScaleType, (spec.yType == "integer") and not isPctY)
	if not spec.yMin then yMin = niceYMin end
	if not spec.yMax then yMax = niceYMax end
	if yMax <= yMin then yMax = yMin + 1 end

	local maxLblLen = 3
	for j = 1, nAxes do
		local lbl = tostring(xArr[j] or j)
		maxLblLen = max(maxLblLen, mw.ustring.len(lbl))
	end

	local legW = 0
	if spec.showLegend and nSeries > 0 then
		for i = 1, nSeries do
			local t = spec.seriesTitles[i] or ("y" .. i)
			legW = max(legW, mw.ustring.len(t) * 7.5 + 34)
		end
		if spec.legendTitle then
			legW = max(legW, mw.ustring.len(spec.legendTitle) * 8.5 + 20)
		end
		legW = min(220, max(85, legW))
	end

	local sidePad = max(44, min(110, maxLblLen * 9 + 16))
	local padL = sidePad
	local padR = sidePad + legW
	local padT = (spec.title and 34 or 16) + 18
	local padB = 28

	local outerR = min(plotW, plotH) / 2
	if spec.outerRadius and spec.outerRadius > 0 then
		outerR = min(outerR, spec.outerRadius)
	end

	local totalW = padL + plotW + padR
	local totalH = max(padT + plotH + padB, padT + (spec.showLegend and (nSeries * 19 + 30) or 0) + padB)
	local cx = padL + plotW / 2
	local cy = padT + plotH / 2

	local angScale = (xUnit == "turn") and (2 * pi) or ((xUnit == "degree") and (pi / 180) or 1.0)
	local function getAngle(idx)
		if isContinuousPolar then
			return toSvgAngle((xNums[idx] or 0) * angScale)
		end
		return toSvgAngle(((idx - 1) / nAxes) * (2 * pi))
	end
	local function getRadius(val)
		local t = max(0, min(1, applyScale(val, yMin, yMax, spec.yScaleType)))
		return outerR * t
	end

	local customState = { defs = {}, map = {}, count = 0, allowImgOverlay = true }
	local buf = { "" }

	if spec.title then
		buf[#buf + 1] = format('<text x="%.1f" y="22" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>', cx, grey, esc(spec.title))
	end

	local useCircleGrid = isContinuousPolar or (spec.interpolate == "circle" or spec.interpolate == "round")
	for tIdx, tk in ipairs(yTicks) do
		if tk > yMin + 1e-9 and tk <= yMax + 1e-9 then
			local r = getRadius(tk)
			local isOuter = (tIdx == #yTicks)
			local strokeCol = isOuter and "#b5bac0" or "#e1e3e5"
			local sw = isOuter and 1.3 or 1.0
			if useCircleGrid then
				buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="%s" stroke-width="%.1f"/>', cx, cy, r, strokeCol, sw)
			else
				local ringPts = {}
				for j = 1, nAxes do
					local ang = getAngle(j)
					ringPts[#ringPts + 1] = format("%.2f,%.2f", cx + r * cos(ang), cy + r * sin(ang))
				end
				buf[#buf + 1] = format('<polygon points="%s" fill="none" stroke="%s" stroke-width="%.1f"/>', table.concat(ringPts, " "), strokeCol, sw)
			end
		end
	end

	if isContinuousPolar then
		for k = 0, 7 do
			local svgAng = toSvgAngle(k * (pi / 4))
			local cosA, sinA = cos(svgAng), sin(svgAng)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0d5da" stroke-width="1"/>',
				cx, cy, cx + outerR * cosA, cy + outerR * sinA)
			if not spec.hideAxes then
				local lblR = outerR + 13
				local lx, ly = cx + lblR * cosA, cy + lblR * sinA
				local lbl = formatPolarRadialTick(k, xUnit, spec.xAxisFormat)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="10px" font-weight="bold" fill="%s">%s</text>',
					lx, ly + 3.5, grey, esc(lbl))
			end
		end
	else
		for j = 1, nAxes do
			local ang = getAngle(j)
			local cosA, sinA = cos(ang), sin(ang)
			local ex, ey = cx + outerR * cosA, cy + outerR * sinA
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0d5da" stroke-width="1"/>', cx, cy, ex, ey)

			if not spec.hideAxes then
				local lbl = tostring(xArr[j] or j)
				local lblR = outerR + 11
				local lx, ly = cx + lblR * cosA, cy + lblR * sinA
				local anchor = "middle"
				if cosA > 0.18 then anchor = "start"
				elseif cosA < -0.18 then anchor = "end" end

				local dy = 4
				if sinA < -0.55 then dy = -2
				elseif sinA > 0.55 then dy = 9 end

				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-size="11px" font-weight="bold" fill="%s">%s</text>', lx, ly + dy, anchor, grey, esc(lbl))
			end
		end
	end

	if not spec.hideAxes then
		for _, tk in ipairs(yTicks) do
			if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
				local r = getRadius(tk)
				local lbl = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer")
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-size="10px" fill="#72777d" data-halo="1">%s</text>', cx - 4, cy - r + 3.5, esc(lbl))
			end
		end
	end

	local symBuf = {}
	local valTextBuf = {}
	local tipBuf = {}

	local cMin = spec.domainMin or yMin
	local cMax = spec.domainMax or yMax
	if cMax <= cMin then cMax = cMin + 1 end

	for i = 1, nSeries do
		local c = spec.colors[(i - 1) % #spec.colors + 1]
		local alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 1
		local fillAlpha = spec.isPolarLine and 0 or ((alpha < 1) and alpha or 0.25)
		local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or 2.0
		local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
		local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
		local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
		local polyCol = baseCol
		if hasSeriesCmap then
			local gradId = "gr-rad-cmap-" .. i
			customState.defs[#customState.defs + 1] = Utils.buildColormapRadialDef(gradId, sCmap, cx, cy, outerR)
			customState.count = (customState.count or 0) + 1
			polyCol = "url(#" .. gradId .. ")"
		end
		local polyPts = {}

		for j = 1, nAxes do
			local rawY = ySeries[i] and ySeries[i][j]
			local yv = (rawY ~= false and rawY ~= nil) and tonumber(rawY) or yMin
			local ptCol = hasSeriesCmap and Utils.sampleColormap((yv - cMin) / (cMax - cMin), sCmap) or baseCol
			local r = getRadius(yv)
			local ang = getAngle(j)
			local px, py = cx + r * cos(ang), cy + r * sin(ang)
			polyPts[#polyPts + 1] = format("%.2f,%.2f", px, py)

			if spec.symbols and spec.symbols.enabled then
				local symR = spec.symbols.radii[i] or spec.symbols.radii[1] or 3.2
				local shp = spec.symbols.shapes[i] or spec.symbols.shapes[1] or "circle"
				local fillC = spec.symbols.noFill and "none" or ptCol
				symBuf[#symBuf + 1] = renderSymbol(shp, px, py, symR, fillC, ptCol, spec.symbols.strokeWidth, alpha, customState)
			end

			if spec.showValues and rawY ~= false and rawY ~= nil then
				local sv = spec.showValues
				local fSize = tonumber(sv.fontsize) or 10
				local fColor = sv.fontcolor or ptCol
				local txt = formatNumber(yv, sv.format, spec.yType == "integer")
				valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" font-weight="bold" fill="%s" data-halo="1">%s</text>',
					px, py - 6, fSize, fColor, esc(txt))
			end

			if spec.interactive ~= false and rawY ~= false and rawY ~= nil then
				local sTitle = spec.seriesTitles[i] or ("y" .. i)
				local axisLbl
				if xUnit == "turn" then
					local hasPct = spec.xAxisFormat and (find(spec.xAxisFormat, "%", 1, true) or find(spec.xAxisFormat, "‰", 1, true))
					axisLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. (hasPct and "" or " turn")
				elseif xUnit == "radian" then
					axisLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. " rad"
				elseif xUnit == "degree" then
					axisLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. "°"
				else
					axisLbl = tostring(xArr[j] or j)
				end
				local valStr = formatNumber(yv, spec.yAxisFormat, spec.yType == "integer")
				local tipHtml = (nSeries > 1)
					and format('<b>%s</b> (%s): %s', esc(sTitle), esc(axisLbl), esc(valStr))
					or format('<b>%s</b>: %s', esc(axisLbl), esc(valStr))
				local swSpec = (spec.symbols and spec.symbols.enabled) and {
					shape = spec.symbols.shapes[i] or spec.symbols.shapes[1] or "circle",
					fill = spec.symbols.noFill and "none" or ptCol,
					stroke = ptCol,
					strokeWidth = spec.symbols.strokeWidth,
					r = spec.symbols.radii[i] or spec.symbols.radii[1] or 3.2
				} or nil
				tipBuf[#tipBuf + 1] = buildPointHit(px, py, totalW, totalH, tipHtml, ptCol, 16, swSpec)
			end
		end

		if spec.isPolarLine and #polyPts >= 2 then
			buf[#buf + 1] = format('<polyline points="%s" fill="none" stroke="%s" stroke-width="%.2f" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="%.2f"/>',
				table.concat(polyPts, " "), polyCol, lw, alpha)
		elseif #polyPts >= 3 then
			buf[#buf + 1] = format('<polygon points="%s" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.2f" stroke-linejoin="round" stroke-opacity="%.2f"/>',
				table.concat(polyPts, " "), polyCol, fillAlpha, polyCol, lw, alpha)
		end
	end

	for k = 1, #symBuf do buf[#buf + 1] = symBuf[k] end
	for k = 1, #valTextBuf do buf[#buf + 1] = valTextBuf[k] end

	if spec.showLegend and nSeries > 0 then
		local legX = padL + plotW + sidePad - 10
		local legY = padT + 4
		if spec.legendTitle then
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="12px" font-weight="bold" fill="%s">%s</text>', legX, legY + 10, grey, esc(spec.legendTitle))
			legY = legY + 20
		end
		for i = 1, nSeries do
			local c = spec.colors[(i - 1) % #spec.colors + 1]
			local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
			local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
			local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
			local iy = legY + (i - 1) * 18
			if hasSeriesCmap then
				local gradId = "gr-rad-leg-cmap-" .. i
				customState.defs[#customState.defs + 1] = Utils.buildColormapGradientDef(gradId, sCmap, "0%", "0%", "100%", "0%")
				customState.count = (customState.count or 0) + 1
				buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="16" height="5" rx="1" fill="url(#%s)"/>', legX, iy + 3, gradId)
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>', legX + 20, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
			else
				buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s" fill-opacity="0.4" stroke="%s" stroke-width="1.5"/>', legX, iy, baseCol, baseCol)
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>', legX + 18, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
			end
		end
	end

	if customState.count > 0 then
		buf[1] = format('<defs>%s</defs>', table.concat(customState.defs))
	end

	local animCls = (spec.animate ~= false) and "gr-anim gr-anim-radial" or nil
	local extraOv = FlushImgOverlays(customState, totalW, totalH) .. table.concat(tipBuf)
	TrackingCategory.append('有SVG圖表的頁面')
	return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', customState.count > 0, extraOv, animCls)
end

-- 極座標長條圖 / 直方圖 / 南丁格爾玫瑰圖渲染器 (Polar Bar / Polar Histogram / Rose Chart)
local function renderPolarBarChart(spec)
	local plotW = max(140, spec.width or 280)
	local plotH = max(140, spec.height or 280)
	local grey = spec.persistentGrey or "#54595d"
	local stacked = spec.stacked
	local reverseY = (spec.reverseY == true)

	local xArr = spec.x or {}
	local ySeries = spec.y or {}
	local zSeries = spec.z or {}
	local nSeries = max(1, #ySeries)
	local nPoints = xArr.n or #xArr
	for i = 1, nSeries do
		local len = ySeries[i] and (ySeries[i].n or #ySeries[i]) or 0
		if len > nPoints then nPoints = len end
	end
	nPoints = max(1, nPoints)

	local hasExplicitZ = (type(zSeries) == "table" and #zSeries > 0)
	local allXNumeric = true
	local xNums = {}
	local dataXMin, dataXMax = math.huge, -math.huge
	for j = 1, nPoints do
		local xv = tonumber(xArr[j])
		if xv == nil and xArr[j] ~= nil and xArr[j] ~= false then
			allXNumeric = false
		end
		xNums[j] = xv
		if xv then
			if xv < dataXMin then dataXMin = xv end
			if xv > dataXMax then dataXMax = xv end
		end
	end

	local maxZWidth = 0
	if hasExplicitZ then
		for i = 1, #zSeries do
			local zArr = zSeries[i] or {}
			for j = 1, nPoints do
				local zw = tonumber(zArr[j])
				if zw and abs(zw) > maxZWidth then maxZWidth = abs(zw) end
			end
		end
	end

	local xUnit = resolvePolarXUnit(spec, allXNumeric, dataXMax, maxZWidth, hasExplicitZ or spec.polarHist)
	local isContinuousPolar = (xUnit == "turn" or xUnit == "radian" or xUnit == "degree")
	local defaultStartDeg = isContinuousPolar and 0 or -270
	local _, _, toSvgAngle = resolvePolarTransform(spec, defaultStartDeg)

	local stackedY0, stackedY1 = {}, {}
	local dataYMin, dataYMax = 0, -math.huge
	for i = 1, nSeries do
		stackedY0[i] = {}
		stackedY1[i] = {}
	end
	for j = 1, nPoints do
		local posSum = 0
		for i = 1, nSeries do
			local rawV = ySeries[i] and ySeries[i][j]
			local v = (rawV ~= false and rawV ~= nil) and tonumber(rawV) or nil
			if v ~= nil then
				if stacked then
					stackedY0[i][j] = posSum
					posSum = posSum + max(0, v)
					stackedY1[i][j] = posSum
				else
					stackedY0[i][j] = 0
					stackedY1[i][j] = v
				end
				if stackedY0[i][j] < dataYMin then dataYMin = stackedY0[i][j] end
				if stackedY1[i][j] < dataYMin then dataYMin = stackedY1[i][j] end
				if stackedY1[i][j] > dataYMax then dataYMax = stackedY1[i][j] end
			end
		end
	end
	if dataYMax == -math.huge then dataYMin, dataYMax = 0, 10 end
	if dataYMin == dataYMax then dataYMax = dataYMin + 10 end

	local yMin = tonumber(spec.yMin) or min(0, dataYMin)
	local yMax = tonumber(spec.yMax) or dataYMax
	local isPctY = spec.yAxisFormat and (find(spec.yAxisFormat, "%", 1, true) ~= nil or find(spec.yAxisFormat, "‰", 1, true) ~= nil)
	local yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, 4, spec.yScaleType, (spec.yType == "integer") and not isPctY)
	if not spec.yMin then yMin = niceYMin end
	if not spec.yMax then yMax = niceYMax end
	if yMax <= yMin then yMax = yMin + 1 end

	local cMin = spec.domainMin or yMin
	local cMax = spec.domainMax or yMax
	if cMax <= cMin then cMax = cMin + 1 end

	local legW = 0
	if spec.showLegend and nSeries > 0 then
		for i = 1, nSeries do
			local t = spec.seriesTitles[i] or ("y" .. i)
			legW = max(legW, mw.ustring.len(t) * 7.5 + 36)
		end
		if spec.legendTitle then
			legW = max(legW, mw.ustring.len(spec.legendTitle) * 8.5 + 20)
		end
		legW = min(220, max(85, legW))
	end

	local sidePad = spec.hideAxes and 14 or 42
	local padL = sidePad
	local padR = sidePad + legW
	local padT = (spec.title and 32 or 14) + (spec.hideAxes and 0 or 12)
	local padB = spec.hideAxes and 14 or 24

	local outerR = min(plotW, plotH) / 2
	if spec.outerRadius and spec.outerRadius > 0 then
		outerR = min(outerR, spec.outerRadius)
	end
	local innerR = max(0, min(outerR - 6, tonumber(spec.innerRadius) or 0))

	local totalW = padL + plotW + padR
	local totalH = max(padT + plotH + padB, padT + (spec.showLegend and (nSeries * 19 + 30) or 0) + padB)
	local cx = padL + plotW / 2
	local cy = padT + plotH / 2

	local function mapR(val)
		local t = max(0, min(1, applyScale(val, yMin, yMax, spec.yScaleType)))
		if reverseY then
			return outerR - t * (outerR - innerR)
		end
		return innerR + t * (outerR - innerR)
	end

	local function buildSectorPath(r0, r1, svgA0, svgA1)
		local rLo, rHi = min(r0, r1), max(r0, r1)
		local dAng = svgA1 - svgA0
		if dAng <= 1e-6 or rHi <= 0.2 then return nil end
		if dAng >= 2 * pi - 1e-4 then
			if rLo > 0.4 then
				return format("M %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f M %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f Z",
					cx, cy - rHi, rHi, rHi, cx, cy + rHi, rHi, rHi, cx, cy - rHi,
					cx, cy - rLo, rLo, rLo, cx, cy + rLo, rLo, rLo, cx, cy - rLo)
			end
			return format("M %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f Z",
				cx, cy - rHi, rHi, rHi, cx, cy + rHi, rHi, rHi, cx, cy - rHi)
		end
		local largeArc = (dAng > pi) and 1 or 0
		local x1, y1 = cx + rHi * cos(svgA0), cy + rHi * sin(svgA0)
		local x2, y2 = cx + rHi * cos(svgA1), cy + rHi * sin(svgA1)
		if rLo > 0.4 then
			local ix1, iy1 = cx + rLo * cos(svgA1), cy + rLo * sin(svgA1)
			local ix2, iy2 = cx + rLo * cos(svgA0), cy + rLo * sin(svgA0)
			return format("M %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,0 %.2f,%.2f Z",
				x1, y1, rHi, rHi, largeArc, x2, y2, ix1, iy1, rLo, rLo, largeArc, ix2, iy2)
		else
			return format("M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f Z",
				cx, cy, x1, y1, rHi, rHi, largeArc, x2, y2)
		end
	end

	local defsBuf = {}
	local buf = { "" }

	-- 1. 繪製極座標同心圓格線與放射軸線
	if innerR > 0.5 then
		buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#888888" stroke-width="1"/>', cx, cy, innerR)
	end
	for _, tk in ipairs(yTicks) do
		if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
			local r = mapR(tk)
			if r > 0.5 and abs(r - outerR) > 1.0 then
				buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#b8bdc2" stroke-width="0.9"/>', cx, cy, r)
			end
		end
	end

	if isContinuousPolar then
		-- 連續角度/弧度/轉數模式:從 startAngle 起算,依旋轉方向繪製 8 條極座標放射軸線
		for k = 0, 7 do
			local svgAng = toSvgAngle(k * (pi / 4))
			local x0, y0 = cx + innerR * cos(svgAng), cy + innerR * sin(svgAng)
			local x1, y1 = cx + outerR * cos(svgAng), cy + outerR * sin(svgAng)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b8bdc2" stroke-width="0.9"/>', x0, y0, x1, y1)
			if not spec.hideAxes then
				local lblR = outerR + 14
				local lx, ly = cx + lblR * cos(svgAng), cy + lblR * sin(svgAng)
				local lbl = formatPolarRadialTick(k, xUnit, spec.xAxisFormat)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="10px" fill="%s">%s</text>', lx, ly + 3.5, grey, esc(lbl))
			end
		end
	else
		-- 離散類別模式:從 startAngle 起算,依類別數量均分放射軸線
		for j = 1, nPoints do
			local svgAng = toSvgAngle(((j - 1) / nPoints) * (2 * pi))
			local x0, y0 = cx + innerR * cos(svgAng), cy + innerR * sin(svgAng)
			local x1, y1 = cx + outerR * cos(svgAng), cy + outerR * sin(svgAng)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0d5da" stroke-width="0.9"/>', x0, y0, x1, y1)
			if not spec.hideAxes then
				local midBandAng = toSvgAngle(((j - 0.5) / nPoints) * (2 * pi))
				local lblR = outerR + 13
				local lx, ly = cx + lblR * cos(midBandAng), cy + lblR * sin(midBandAng)
				local cosA = cos(midBandAng)
				local anchor = (cosA > 0.2) and "start" or ((cosA < -0.2) and "end" or "middle")
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-size="11px" font-weight="bold" fill="%s">%s</text>',
					lx, ly + 3.8, anchor, grey, esc(tostring(xArr[j] or j)))
			end
		end
	end

	-- 2. 計算各扇形角度區間並繪製扇形長條
	local defaultPad = (spec.polarHist or hasExplicitZ) and 0.0 or ((nSeries > 1 and not stacked) and 0.22 or 0.16)
	local padRatio = max(0, min(0.85, tonumber(spec.barPadding) or defaultPad))
	local grpPadRatio = max(0, min(0.6, tonumber(spec.groupPadding) or 0.0))

	local sectorBuf = {}
	local valTextBuf = {}
	local tipBuf = {}
	local MAX_TIPS = 100
	local totalValid = 0
	for i = 1, nSeries do
		for j = 1, nPoints do
			if stackedY1[i][j] ~= nil then totalValid = totalValid + 1 end
		end
	end
	local tipStep = (totalValid > MAX_TIPS) and (totalValid / MAX_TIPS) or 1
	local curValidIdx = 0
	local nextTipIdx = 1

	local sortedX = nil
	if isContinuousPolar and not hasExplicitZ and nPoints > 1 then
		sortedX = {}
		for j = 1, nPoints do
			if xNums[j] then sortedX[#sortedX + 1] = xNums[j] end
		end
		table.sort(sortedX)
	end

	local isEdgePolar = (spec.barAlign ~= "center")
	local angScale = (xUnit == "turn") and (2 * pi) or ((xUnit == "degree") and (pi / 180) or 1.0)

	for j = 1, nPoints do
		local bandRad0, bandRad1
		if isContinuousPolar then
			local cAng = (xNums[j] or 0) * angScale
			local wAng
			if hasExplicitZ then
				local rawZ = (zSeries[1] and tonumber(zSeries[1][j])) or 0.3
				wAng = abs(rawZ) * angScale
			elseif sortedX and #sortedX > 1 then
				local minDiff = math.huge
				for k = 2, #sortedX do
					local d = sortedX[k] - sortedX[k - 1]
					if d > 1e-6 and d < minDiff then minDiff = d end
				end
				wAng = ((minDiff < math.huge) and minDiff or ((2 * pi / angScale) / nPoints)) * angScale
			else
				wAng = (2 * pi) / nPoints
			end
			local rawStart = isEdgePolar and cAng or (cAng - wAng * 0.5)
			local rawEnd = isEdgePolar and (cAng + wAng) or (cAng + wAng * 0.5)
			local midAng = (rawStart + rawEnd) * 0.5
			local usableW = wAng * (1 - padRatio)
			bandRad0 = midAng - usableW * 0.5
			bandRad1 = midAng + usableW * 0.5
		else
			local fullBand = (2 * pi) / nPoints
			local usableW = fullBand * (1 - padRatio)
			local centerRad = (j - 0.5) * fullBand
			bandRad0 = centerRad - usableW * 0.5
			bandRad1 = centerRad + usableW * 0.5
		end

		local bandSpan = bandRad1 - bandRad0
		local nSub = stacked and 1 or nSeries
		local subStep = bandSpan / nSub
		local subW = subStep * (1 - (nSub > 1 and grpPadRatio or 0))

		for i = 1, nSeries do
			local y0v = stackedY0[i][j]
			local y1v = stackedY1[i][j]
			if y1v ~= nil then
				curValidIdx = curValidIdx + 1
				local rawVal = tonumber(ySeries[i][j]) or y1v
				local zVal = hasExplicitZ and ((zSeries[i] and tonumber(zSeries[i][j])) or (zSeries[1] and tonumber(zSeries[1][j]))) or nil

				local rStart, rEnd = bandRad0, bandRad1
				if not stacked and nSeries > 1 then
					if hasExplicitZ and zSeries[i] and tonumber(zSeries[i][j]) and isContinuousPolar then
						local cAng = (xNums[j] or 0) * angScale
						local fullW = abs(tonumber(zSeries[i][j])) * angScale
						local rawStart = isEdgePolar and cAng or (cAng - fullW * 0.5)
						local midAng = rawStart + fullW * 0.5
						local wAng = fullW * (1 - padRatio)
						local subSpan = wAng / nSeries
						rStart = (midAng - wAng * 0.5) + (i - 1) * subSpan
						rEnd = rStart + subSpan * (1 - grpPadRatio)
					else
						rStart = bandRad0 + (i - 1) * subStep + (subStep - subW) * 0.5
						rEnd = rStart + subW
					end
				end

				local a0, a1 = toSvgAngle(rStart), toSvgAngle(rEnd)
				local sA0, sA1 = min(a0, a1), max(a0, a1)

				local r0, r1 = mapR(y0v), mapR(y1v)
				local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
				local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
				local baseCol = (sCmap and not hasSeriesCmap) and sCmap or spec.colors[(i - 1) % #spec.colors + 1]
				local col
				if hasSeriesCmap then
					col = Utils.sampleColormap((rawVal - cMin) / (cMax - cMin), sCmap)
				elseif nSeries == 1 and not spec.colors[2] then
					col = baseCol
				elseif nSeries == 1 then
					col = spec.colors[(j - 1) % #spec.colors + 1]
				else
					col = baseCol
				end
				local alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 0.82
				if alpha >= 1.0 and isContinuousPolar then alpha = 0.82 end
				local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or 1.4
				local strokeCol = (lw > 0) and "#333333" or "none"
				local pathD = buildSectorPath(r0, r1, sA0, sA1)
				if pathD then
					sectorBuf[#sectorBuf + 1] = format('<path d="%s" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.2f" stroke-linejoin="round"/>',
						pathD, col, alpha, strokeCol, lw)
				end

				local midA = (sA0 + sA1) * 0.5
				if spec.showValues then
					local sv = spec.showValues
					local fSize = tonumber(sv.fontsize) or 10
					local fColor = sv.fontcolor or grey
					local lblR = max(r0, r1) + (tonumber(sv.offset) or 10)
					local lx, ly = cx + lblR * cos(midA), cy + lblR * sin(midA)
					local txt = formatNumber(rawVal, sv.format or spec.yAxisFormat, spec.yType == "integer")
					valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" font-weight="bold" fill="%s" data-halo="1">%s</text>',
						lx, ly + 3.5, fSize, fColor, esc(txt))
				end

				if spec.interactive ~= false and #tipBuf < MAX_TIPS and curValidIdx >= floor(nextTipIdx + 1e-9) then
					nextTipIdx = nextTipIdx + tipStep
					local xLbl, zSuffix = "", ""
					local hasPctFmt = spec.xAxisFormat and (find(spec.xAxisFormat, "%", 1, true) or find(spec.xAxisFormat, "‰", 1, true))
					local unitSuffix = (xUnit == "degree") and "°" or ((xUnit == "radian") and " rad" or ((xUnit == "turn" and not hasPctFmt) and " turn" or ""))
					if isContinuousPolar then
						xLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. unitSuffix
					else
						xLbl = tostring(xArr[j] or j)
					end
					if zVal then
						local zFmtStr = hasPctFmt and formatNumber(zVal, spec.xAxisFormat, false) or formatNumber(zVal, nil, false)
						zSuffix = format(' (width: %s%s)', esc(zFmtStr), unitSuffix)
					end
					local vStr = formatNumber(rawVal, spec.yAxisFormat, spec.yType == "integer")
					local sTitle = spec.seriesTitles[i] or ("y" .. i)
					local tipHtml = (nSeries > 1 or (sTitle ~= "y1" and sTitle ~= "y"))
						and format('<b>%s</b> (%s): %s%s', esc(sTitle), esc(xLbl), esc(vStr), zSuffix)
						or format('<b>%s</b>: %s%s', esc(xLbl), esc(vStr), zSuffix)
					tipBuf[#tipBuf + 1] = buildSectorHit(cx, cy, r0, r1, sA0, sA1, totalW, totalH, tipHtml, col)
				end
			end
		end
	end

	for k = 1, #sectorBuf do buf[#buf + 1] = sectorBuf[k] end

	-- 3. 繪製外圓主框線與徑向刻度文字
	buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#222222" stroke-width="1.6"/>', cx, cy, outerR)

	if not spec.hideAxes then
		for _, tk in ipairs(yTicks) do
			if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
				local r = mapR(tk)
				local lbl = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer")
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-size="10px" fill="#54595d" data-halo="1">%s</text>',
					cx - 4, cy - r + 3.5, esc(lbl))
			end
		end
	end

	for k = 1, #valTextBuf do buf[#buf + 1] = valTextBuf[k] end

	if spec.title then
		buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>', cx, grey, esc(spec.title))
	end

	if spec.showLegend and nSeries > 0 then
		local legX = padL + plotW + sidePad - 10
		local legY = padT + 4
		if spec.legendTitle then
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="12px" font-weight="bold" fill="%s">%s</text>', legX, legY + 10, grey, esc(spec.legendTitle))
			legY = legY + 20
		end
		for i = 1, nSeries do
			local c = spec.colors[(i - 1) % #spec.colors + 1]
			local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
			local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
			local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
			local iy = legY + (i - 1) * 18
			if hasSeriesCmap then
				local gradId = "gr-pb-leg-cmap-" .. i
				defsBuf[#defsBuf + 1] = Utils.buildColormapGradientDef(gradId, sCmap, "0%", "0%", "100%", "0%")
				buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="16" height="5" rx="1" fill="url(#%s)"/>', legX, iy + 3, gradId)
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>', legX + 20, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
			else
				buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s" stroke="#333333" stroke-width="0.8"/>', legX, iy, baseCol)
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>', legX + 18, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
			end
		end
	end

	if #defsBuf > 0 then
		buf[1] = format('<defs>%s</defs>', table.concat(defsBuf))
	end

	local animCls = (spec.animate ~= false) and "gr-anim gr-anim-radial" or nil
	TrackingCategory.append('有SVG圖表的頁面')
	return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end

p.renderPieChart = renderPieChart
p.renderRangedRectChart = renderRangedRectChart
p.renderBoxPlotChart = renderBoxPlotChart
p.renderGanttChart = renderGanttChart
p.renderHeatmapChart = renderHeatmapChart
p.renderRadarChart = renderRadarChart
p.renderPolarBarChart = renderPolarBarChart

return p