跳转到内容

模組:Graph:MultiChart

维基百科,自由的百科全书
-- Module:Graph:MultiChart
local p = {}
local Graph = require('Module:Graph')
local TrackingCategory = require('Module:TrackingCategory')
--{{#invoke:Graph:MultiChart|mergeChart}}
local format, floor, ceil, abs, max, min = string.format, math.floor, math.ceil, math.abs, math.max, math.min
local log10, pow, sin, pi = math.log10, math.pow, math.sin, math.pi

local MONTH_NAMES = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }

local function esc(s)
	return mw.text.encode(mw.text.unstrip(tostring(s or "")))
end

local function addCommas(numStr)
	local sign, int, frac = string.match(numStr, "^([%+%-]?)(%d+)(%.?%d*)$")
	if not int then return numStr end
	int = string.gsub(string.reverse(int), "(%d%d%d)", "%1,")
	int = string.gsub(string.reverse(int), "^,", "")
	return sign .. int .. frac
end

local function formatNumber(val, fmt, isInt)
	if val == nil or val ~= val then return "" end
	if abs(val) < 1e-12 then val = 0 end
	if not fmt or fmt == "" then
		if isInt then return tostring(floor(val + 0.5)) end
		return format("%g", tonumber(format("%.6g", val)) or val)
	end
	if string.sub(fmt, 1, 1) == "%" and not string.match(fmt, "^%%[d%.fFgGeE]") then
		fmt = string.sub(fmt, 2)
	end
	local useComma = string.find(fmt, ",", 1, true) ~= nil
	local cleanFmt = string.gsub(fmt, ",", "")

	local out
	if string.find(cleanFmt, "%%", 1, true) then
		local prec = tonumber(string.match(cleanFmt, "%.(%d+)")) or 0
		out = format("%." .. prec .. "f%%", val * 100)
	elseif string.find(cleanFmt, "s", 1, true) then
		local av = abs(val)
		if av >= 1e9 then out = format("%.2gG", val / 1e9)
		elseif av >= 1e6 then out = format("%.2gM", val / 1e6)
		elseif av >= 1e3 then out = format("%.2gk", val / 1e3)
		else out = format("%g", val) end
	elseif cleanFmt == "d" or cleanFmt == "i" then
		out = tostring(floor(val + 0.5))
	else
		local prec = tonumber(string.match(cleanFmt, "%.(%d+)"))
		if prec then
			out = format("%." .. prec .. "f", val)
		else
			local ok, res = pcall(format, "%" .. cleanFmt, val)
			out = (ok and res) or format("%g", val)
		end
	end

	if useComma then
		local pct = ""
		if string.sub(out, -1) == "%" then
			pct = "%"
			out = string.sub(out, 1, -2)
		end
		out = addCommas(out) .. pct
	end
	return out
end

local function parseDateValue(str)
	if type(str) == "number" then return str end
	str = mw.text.trim(tostring(str or ""))
	local y, m, d = string.match(str, "^(%d%d%d%d)[%-%/](%d%d?)[%-%/](%d%d?)")
	if y then return tonumber(y) + (tonumber(m) - 1) / 12 + (tonumber(d) - 1) / 365.25 end
	y, m = string.match(str, "^(%d%d%d%d)[%-%/](%d%d?)$")
	if y then return tonumber(y) + (tonumber(m) - 1) / 12 end
	y = string.match(str, "^(%d%d%d%d)$")
	if y then return tonumber(y) end
	return nil
end

local function formatDateValue(val, fmt)
	if not val then return "" end
	local y = floor(val + 1e-6)
	local rem = max(0, val - y)
	local m = min(12, floor(rem * 12 + 1e-4) + 1)
	local d = min(31, max(1, floor((rem - (m - 1) / 12) * 365.25 + 0.5) + 1))

	if not fmt or fmt == "" then
		if abs(val - y) < 1e-3 then return format("%04d", y) end
		return format("%04d-%02d", y, m)
	end
	local out = fmt
	out = string.gsub(out, "%%Y", format("%04d", y))
	out = string.gsub(out, "%%y", format("%02d", y % 100))
	out = string.gsub(out, "%%m", format("%02d", m))
	out = string.gsub(out, "%%d", format("%02d", d))
	out = string.gsub(out, "%%b", MONTH_NAMES[m] or "")
	out = string.gsub(out, "%%B", MONTH_NAMES[m] or "")
	return out
end

local function computeNiceTicks(dMin, dMax, maxTicks, scaleType, isInt)
	if dMin > dMax then dMin, dMax = dMax, dMin end
	if scaleType == "log" then
		local pMin = floor(log10(max(1e-9, dMin)))
		local pMax = ceil(log10(max(1e-8, dMax)))
		if pMin == pMax then pMax = pMin + 1 end
		local ticks = {}
		local step = max(1, ceil((pMax - pMin) / maxTicks))
		for pExp = pMin, pMax, step do ticks[#ticks + 1] = 10 ^ pExp end
		return ticks, 10 ^ pMin, 10 ^ pMax
	end

	local span = dMax - dMin
	if span <= 0 then span = 1 end
	local rawStep = span / max(1, maxTicks)
	local mag = 10 ^ floor(log10(rawStep))
	local residual = rawStep / mag
	local step
	if residual > 5 then step = 10 * mag
	elseif residual > 2 then step = 5 * mag
	elseif residual > 1 then step = 2 * mag
	else step = mag end
	if isInt and step < 1 then step = 1 end

	local niceMin = floor(dMin / step + 1e-9) * step
	local niceMax = ceil(dMax / step - 1e-9) * step
	if niceMin == niceMax then niceMax = niceMin + step end

	local ticks = {}
	for v = niceMin, niceMax + step * 0.1, step do
		if abs(v) < 1e-12 then v = 0 end
		ticks[#ticks + 1] = v
	end
	return ticks, niceMin, niceMax
end

local function applyScale(val, dMin, dMax, scaleType)
	if scaleType == "log" then
		local lMin = log10(max(1e-9, dMin))
		local lMax = log10(max(1e-8, dMax))
		return (log10(max(1e-9, val)) - lMin) / (lMax - lMin)
	elseif scaleType == "sqrt" then
		local sMin = (dMin >= 0 and math.sqrt(dMin) or -math.sqrt(-dMin))
		local sMax = (dMax >= 0 and math.sqrt(dMax) or -math.sqrt(-dMax))
		local sv = (val >= 0 and math.sqrt(val) or -math.sqrt(-val))
		return (sv - sMin) / (sMax - sMin)
	elseif string.match(scaleType or "", "^pow") then
		local exp = tonumber(string.match(scaleType, "([%d%.]+)")) or 2
		local pMin = pow(abs(dMin), exp) * (dMin < 0 and -1 or 1)
		local pMax = pow(abs(dMax), exp) * (dMax < 0 and -1 or 1)
		local pv = pow(abs(val), exp) * (val < 0 and -1 or 1)
		return (pv - pMin) / (pMax - pMin)
	end
	return (val - dMin) / (dMax - dMin)
end

local function buildPathD(pts, interpolate)
	local n = #pts
	if n == 0 then return "" end
	if n == 1 then return format("M %.2f,%.2f", pts[1][1], pts[1][2]) end

	if interpolate == "step" or interpolate == "step-after" then
		local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
		for i = 2, n do b[#b + 1] = format("H %.2f V %.2f", pts[i][1], pts[i][2]) end
		return table.concat(b, " ")
	elseif interpolate == "step-before" then
		local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
		for i = 2, n do b[#b + 1] = format("V %.2f H %.2f", pts[i][2], pts[i][1]) end
		return table.concat(b, " ")
	elseif interpolate == "monotone" or interpolate == "cardinal" or interpolate == "basis" then
		if n == 2 or n > 200 then
			local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
			for i = 2, n do b[#b + 1] = format("L %.2f,%.2f", pts[i][1], pts[i][2]) end
			return table.concat(b, " ")
		end
		local dx_arr, m_arr = {}, {}
		for i = 1, n - 1 do
			local dx = pts[i + 1][1] - pts[i][1]
			if dx <= 1e-5 then
				local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
				for k = 2, n do b[#b + 1] = format("L %.2f,%.2f", pts[k][1], pts[k][2]) end
				return table.concat(b, " ")
			end
			dx_arr[i] = dx
			m_arr[i] = (pts[i + 1][2] - pts[i][2]) / dx
		end
		local t = { [1] = m_arr[1], [n] = m_arr[n - 1] }
		for i = 2, n - 1 do
			local m0, m1 = m_arr[i - 1], m_arr[i]
			if m0 * m1 <= 0 then
				t[i] = 0
			else
				local d0, d1 = dx_arr[i - 1], dx_arr[i]
				local c = d0 + d1
				t[i] = 3 * c / ((c + d1) / m0 + (c + d0) / m1)
			end
		end
		local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
		for i = 1, n - 1 do
			local x0, y0, x1, y1 = pts[i][1], pts[i][2], pts[i + 1][1], pts[i + 1][2]
			local dx3 = dx_arr[i] / 3
			b[#b + 1] = format("C %.2f,%.2f %.2f,%.2f %.2f,%.2f", x0 + dx3, y0 + t[i] * dx3, x1 - dx3, y1 - t[i + 1] * dx3, x1, y1)
		end
		return table.concat(b, " ")
	end

	local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
	for i = 2, n do b[#b + 1] = format("L %.2f,%.2f", pts[i][1], pts[i][2]) end
	return table.concat(b, " ")
end

-- 解析單一子圖表座標軸與資料結構
local function prepareLayer(spec, plotW, plotH)
	local chartType = spec.chartType or "line"
	local stacked = spec.stacked
	local xArr = spec.x or {}
	local ySeries = spec.y or {}
	local nSeries = #ySeries
	local nPoints = xArr.n or #xArr

	local isOrdinalX = (chartType == "rect") or (spec.xType == "string")
	local isDateX = (not isOrdinalX) and (spec.xType == "date")

	local xNums = {}
	local dataXMin, dataXMax = math.huge, -math.huge
	for j = 1, nPoints do
		local rawX = xArr[j]
		local xv
		if isOrdinalX then xv = j
		elseif isDateX then xv = parseDateValue(rawX)
		elseif rawX ~= false and rawX ~= nil then xv = tonumber(rawX) end
		xNums[j] = xv
		if xv then
			if xv < dataXMin then dataXMin = xv end
			if xv > dataXMax then dataXMax = xv end
		end
	end
	if dataXMin == math.huge then dataXMin, dataXMax = 0, max(1, nPoints) end

	local stackedY0, stackedY1 = {}, {}
	local dataYMin = (chartType ~= "line") and 0 or math.huge
	local dataYMax = (chartType ~= "line") and 0 or -math.huge

	for i = 1, nSeries do
		stackedY0[i] = {}
		stackedY1[i] = {}
	end

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

	local xMin = (not isOrdinalX and spec.xMin and (isDateX and parseDateValue(spec.xMin) or tonumber(spec.xMin))) or dataXMin
	local xMax = (not isOrdinalX and spec.xMax and (isDateX and parseDateValue(spec.xMax) or tonumber(spec.xMax))) or dataXMax
	if xMin == xMax then xMax = xMin + 1 end

	local xTicks = {}
	if not isOrdinalX then
		local maxXTicks = max(3, floor(plotW / 55))
		xTicks = computeNiceTicks(xMin, xMax, maxXTicks, spec.xScaleType, spec.xType == "integer")
	end

	local yMin = tonumber(spec.yMin) or dataYMin
	local yMax = tonumber(spec.yMax) or dataYMax
	if spec.yScaleType == "log" and yMin <= 0 then yMin = 0.1 end

	local maxYTicks = max(3, floor(plotH / 36))
	local yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, maxYTicks, spec.yScaleType, spec.yType == "integer")
	if not spec.yMin then yMin = niceYMin end
	if not spec.yMax then yMax = niceYMax end
	if yMin == yMax then yMax = yMin + 1 end

	return {
		spec = spec,
		chartType = chartType,
		stacked = stacked,
		xArr = xArr,
		ySeries = ySeries,
		nSeries = nSeries,
		nPoints = nPoints,
		isOrdinalX = isOrdinalX,
		isDateX = isDateX,
		xNums = xNums,
		stackedY0 = stackedY0,
		stackedY1 = stackedY1,
		xMin = xMin,
		xMax = xMax,
		xTicks = xTicks,
		yMin = yMin,
		yMax = yMax,
		yTicks = yTicks
	}
end

-- 子圖表入口:將子圖表參數序列化為 JSON 供外層 mergeChart 讀取
function p.subChart(frame)
	local merged = {}
	local parent = frame and frame.getParent and frame:getParent()
	if parent and parent.args then
		for k, v in pairs(parent.args) do
			if v ~= "" then merged[tostring(k)] = v end
		end
	end
	if frame and frame.args then
		for k, v in pairs(frame.args) do
			if v ~= "" then merged[tostring(k)] = v end
		end
	end
	return mw.text.jsonEncode(merged)
end

-- 合併並渲染多重圖表 SVG
function p.mergeChart(frame)
	local args = (type(frame) == "string") and { frame } or ((type((frame or {}).args) == "table") and frame.args or frame)
	local jsonStr = args['1'] or args[1] or ""
	if mw.text.trim(jsonStr) == "" then return "" end

	local ok, decoded = pcall(mw.text.jsonDecode, jsonStr, mw.text.JSON_TRY_FIXING)
	if not ok or type(decoded) ~= "table" then return "" end
	if decoded[1] == nil then decoded = { decoded } end
	if #decoded < 1 then return "" end

	local specs = {}
	local plotW, plotH = 200, 200
	local colorOffset = 0

	for i = 1, #decoded do
		local item = decoded[i]
		local spec = Graph.buildChartSpec(item)
		-- 若第 2 個以後的子圖表未明確指定 colors,自動偏移預設色盤索引以區分不同圖層顏色
		if i > 1 and not item.colors and spec.colors and #spec.colors > 1 then
			local shiftedColors = {}
			local shiftedAlphas = {}
			for cIdx = 1, #spec.colors do
				local srcIdx = (cIdx - 1 + colorOffset) % #spec.colors + 1
				shiftedColors[cIdx] = spec.colors[srcIdx]
				shiftedAlphas[cIdx] = spec.alphas[srcIdx]
			end
			spec.colors = shiftedColors
			spec.alphas = shiftedAlphas
		end
		colorOffset = colorOffset + max(1, #(spec.y or {}))
		if spec.width then plotW = max(40, spec.width) end
		if spec.height then plotH = max(40, spec.height) end
		specs[i] = spec
	end

	-- 若只有 1 個子圖表,直接交由標準渲染器輸出
	if #specs == 1 then
		return require('Module:Graph/SVGRenderer').renderChart(specs[1])
	end

	local layers = {}
	for i = 1, #specs do
		layers[i] = prepareLayer(specs[i], plotW, plotH)
	end

	local primaryLayer = layers[1]
	local rightYLayer = layers[2] -- 依原版邏輯:第 2 張圖表的 Y 軸配置於右側 (orient="right")
	local grey = primaryLayer.spec.persistentGrey or "#54595d"

	-- 計算左 Y 軸、右 Y 軸與底部邊距
	local maxLeftYLen = 3
	for _, tk in ipairs(primaryLayer.yTicks) do
		local s = formatNumber(tk, primaryLayer.spec.yAxisFormat, primaryLayer.spec.yType == "integer")
		if #s > maxLeftYLen then maxLeftYLen = #s end
	end

	local maxRightYLen = 0
	if rightYLayer then
		for _, tk in ipairs(rightYLayer.yTicks) do
			local s = formatNumber(tk, rightYLayer.spec.yAxisFormat, rightYLayer.spec.yType == "integer")
			if #s > maxRightYLen then maxRightYLen = #s end
		end
	end

	local xAngle = primaryLayer.spec.xAxisAngle or 0
	local maxXLblLen = 4
	if primaryLayer.isOrdinalX then
		for j = 1, primaryLayer.nPoints do
			local len = mw.ustring.len(tostring(primaryLayer.xArr[j] or ""))
			if len > maxXLblLen then maxXLblLen = len end
		end
	end
	local extraBottom = (abs(xAngle) > 10) and min(75, maxXLblLen * 5.5 * abs(sin(xAngle * pi / 180))) or 0

	-- 收集所有圖例項目
	local showAnyLegend = false
	local legendTitle = nil
	local legendEntries = {}
	for i = 1, #layers do
		local lyr = layers[i]
		if lyr.spec.showLegend then
			showAnyLegend = true
			if lyr.spec.legendTitle and not legendTitle then
				legendTitle = lyr.spec.legendTitle
			end
		end
		for sIdx = 1, lyr.nSeries do
			legendEntries[#legendEntries + 1] = {
				title = lyr.spec.seriesTitles[sIdx] or ("y" .. #legendEntries + 1),
				color = lyr.spec.colors[(sIdx - 1) % #lyr.spec.colors + 1],
				chartType = lyr.chartType
			}
		end
	end

	local legW = 0
	if showAnyLegend and #legendEntries > 0 then
		for _, ent in ipairs(legendEntries) do
			legW = max(legW, mw.ustring.len(ent.title) * 7.5 + 34)
		end
		if legendTitle then legW = max(legW, mw.ustring.len(legendTitle) * 8.5 + 20) end
		legW = min(220, max(85, legW))
	end

	local rightAxisW = rightYLayer and (max(28, maxRightYLen * 6.8 + 12) + (rightYLayer.spec.yTitle and 20 or 0)) or 16
	local chartTitle = primaryLayer.spec.title or (rightYLayer and rightYLayer.spec.title)

	local padL = max(36, maxLeftYLen * 6.8 + 14) + (primaryLayer.spec.yTitle and 20 or 0)
	local padR = rightAxisW + legW
	local padT = chartTitle and 30 or 12
	local padB = 26 + extraBottom + (primaryLayer.spec.xTitle and 20 or 0)

	local totalW = padL + plotW + padR
	local totalH = max(padT + plotH + padB, padT + (showAnyLegend and (#legendEntries * 19 + 30) or 0) + 12)

	local function mapLayerX(lyr, val, idx)
		if lyr.isOrdinalX then
			if lyr.chartType == "rect" then
				return padL + ((idx - 0.5) / max(1, lyr.nPoints)) * plotW
			else
				if lyr.nPoints <= 1 then return padL + plotW / 2 end
				return padL + ((idx - 1) / (lyr.nPoints - 1)) * plotW
			end
		end
		return padL + applyScale(val, lyr.xMin, lyr.xMax, lyr.spec.xScaleType) * plotW
	end

	local function mapLayerY(lyr, val)
		return padT + plotH - applyScale(val, lyr.yMin, lyr.yMax, lyr.spec.yScaleType) * plotH
	end

	local buf = {}
	buf[#buf + 1] = format('<defs><clipPath id="mc-clip"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath></defs>', padL, padT, plotW, plotH)

	-- 1. 繪製左 Y 軸刻度與格線 (Layer 1)
	for _, tk in ipairs(primaryLayer.yTicks) do
		if tk >= primaryLayer.yMin - 1e-9 and tk <= primaryLayer.yMax + 1e-9 then
			local py = mapLayerY(primaryLayer, tk)
			if primaryLayer.spec.yGrid 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)
			local lbl = formatNumber(tk, primaryLayer.spec.yAxisFormat, primaryLayer.spec.yType == "integer")
			buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', padL - 8, py + 3.8, grey, esc(lbl))
		end
	end

	-- 2. 繪製右 Y 軸刻度 (Layer 2, orient="right")
	if rightYLayer then
		local rightX = padL + plotW
		for _, tk in ipairs(rightYLayer.yTicks) do
			if tk >= rightYLayer.yMin - 1e-9 and tk <= rightYLayer.yMax + 1e-9 then
				local py = mapLayerY(rightYLayer, tk)
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1"/>', rightX, py, rightX + 5, py, grey)
				local lbl = formatNumber(tk, rightYLayer.spec.yAxisFormat, rightYLayer.spec.yType == "integer")
				buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', rightX + 8, py + 3.8, grey, esc(lbl))
			end
		end
	end

	-- 3. 繪製底部 X 軸刻度 (Layer 1)
	if primaryLayer.isOrdinalX then
		local step = max(1, ceil(primaryLayer.nPoints / max(2, floor(plotW / (abs(xAngle) > 20 and 18 or 42)))))
		for j = 1, primaryLayer.nPoints, step do
			local px = mapLayerX(primaryLayer, j, j)
			if primaryLayer.spec.xGrid then
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + plotH)
			end
			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 = tostring(primaryLayer.xArr[j] or "")
			local ly = padT + plotH + 15
			if xAngle ~= 0 then
				local anchor = (xAngle < 0) and "end" or "start"
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-family="sans-serif" font-size="11px" fill="%s" transform="rotate(%d, %.2f, %.2f)">%s</text>',
					px, ly - 3, anchor, grey, xAngle, px, ly - 3, esc(lbl))
			else
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', px, ly, grey, esc(lbl))
			end
		end
	else
		for _, tk in ipairs(primaryLayer.xTicks) do
			if tk >= primaryLayer.xMin - 1e-9 and tk <= primaryLayer.xMax + 1e-9 then
				local px = mapLayerX(primaryLayer, tk, 1)
				if primaryLayer.spec.xGrid then
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + plotH)
				end
				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 = primaryLayer.isDateX and formatDateValue(tk, primaryLayer.spec.xAxisFormat) or formatNumber(tk, primaryLayer.spec.xAxisFormat, primaryLayer.spec.xType == "integer")
				local ly = padT + plotH + 15
				if xAngle ~= 0 then
					local anchor = (xAngle < 0) and "end" or "start"
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-family="sans-serif" font-size="11px" fill="%s" transform="rotate(%d, %.2f, %.2f)">%s</text>',
						px, ly - 3, anchor, grey, xAngle, px, ly - 3, esc(lbl))
				else
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', px, ly, grey, esc(lbl))
				end
			end
		end
	end

	-- 4. 依序繪製每個子圖表資料圖層
	buf[#buf + 1] = '<g clip-path="url(#mc-clip)">'
	for lIdx = 1, #layers do
		local lyr = layers[lIdx]
		local spec = lyr.spec
		local baseY0 = mapLayerY(lyr, max(lyr.yMin, min(lyr.yMax, 0)))

		for i = 1, lyr.nSeries do
			local color = spec.colors[(i - 1) % #spec.colors + 1]
			local alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 1
			local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or (lyr.chartType == "area" and 1.5 or 2.5)

			if lyr.chartType == "rect" then
				local bandW = (plotW / max(1, lyr.nPoints)) * 0.8
				local subBarW = lyr.stacked and bandW or max(1, (bandW - (lyr.nSeries - 1)) / lyr.nSeries)
				local opAttr = (alpha < 1) and format(' fill-opacity="%.2f"', alpha) or ""

				for j = 1, lyr.nPoints do
					local y1v = lyr.stackedY1[i][j]
					local y0v = lyr.stackedY0[i][j]
					if y1v ~= nil then
						local centerPx = mapLayerX(lyr, j, j)
						local barX = lyr.stacked and (centerPx - bandW / 2) or (centerPx - bandW / 2 + (i - 1) * (subBarW + 1))
						local py1 = mapLayerY(lyr, y1v)
						local py0 = lyr.stacked and mapLayerY(lyr, y0v) or baseY0
						local topY = min(py1, py0)
						local rectH = max(1, abs(py0 - py1))
						buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"%s/>', barX, topY, subBarW, rectH, color, opAttr)
					end
				end
			else
				local segments = {}
				local curSeg = {}
				for j = 1, lyr.nPoints do
					local xv = lyr.xNums[j]
					local y1v = lyr.stackedY1[i][j]
					local y0v = lyr.stackedY0[i][j] or 0
					if xv ~= nil and y1v ~= nil then
						curSeg[#curSeg + 1] = { mapLayerX(lyr, xv, j), mapLayerY(lyr, y1v), mapLayerY(lyr, y0v) }
					else
						if #curSeg > 0 then segments[#segments + 1] = curSeg; curSeg = {} end
					end
				end
				if #curSeg > 0 then segments[#segments + 1] = curSeg end

				for _, seg in ipairs(segments) do
					local topPath = buildPathD(seg, spec.interpolate)
					if lyr.chartType == "area" then
						local areaAlpha = (alpha < 1) and alpha or (lyr.stacked and 0.85 or 0.55)
						if lyr.stacked then
							local revPts = {}
							for k = #seg, 1, -1 do revPts[#revPts + 1] = { seg[k][1], seg[k][3] } end
							local botPath = string.gsub(buildPathD(revPts, spec.interpolate), "^M", "L")
							buf[#buf + 1] = format('<path d="%s %s Z" fill="%s" fill-opacity="%.2f" stroke="none"/>', topPath, botPath, color, areaAlpha)
						else
							buf[#buf + 1] = format('<path d="%s L %.2f,%.2f L %.2f,%.2f Z" fill="%s" fill-opacity="%.2f" stroke="none"/>',
								topPath, seg[#seg][1], baseY0, seg[1][1], baseY0, color, areaAlpha)
						end
					end
					if lw > 0 then
						local sOp = (alpha < 1) and format(' stroke-opacity="%.2f"', alpha) or ""
						buf[#buf + 1] = format('<path d="%s" fill="none" stroke="%s" stroke-width="%.2f" stroke-linecap="round" stroke-linejoin="round"%s/>',
							topPath, color, lw, sOp)
					end
				end
			end
		end
	end
	buf[#buf + 1] = '</g>'

	-- 5. 繪製座標軸主框線 (含右側第 2 Y 軸線)
	if rightYLayer then
		buf[#buf + 1] = format('<polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="%s" stroke-width="1.8"/>',
			padL, padT, padL, padT + plotH, padL + plotW, padT + plotH, padL + plotW, padT, grey)
	else
		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)
	end

	-- 6. 繪製標題與左/右/下軸標題
	if chartTitle then
		buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-family="sans-serif" font-size="14px" font-weight="bold" fill="%s">%s</text>',
			padL + plotW / 2, grey, esc(chartTitle))
	end
	if primaryLayer.spec.xTitle then
		buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s">%s</text>',
			padL + plotW / 2, padT + plotH + padB - 6, grey, esc(primaryLayer.spec.xTitle))
	end
	if primaryLayer.spec.yTitle then
		local yc = padT + plotH / 2
		buf[#buf + 1] = format('<text x="14" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s" transform="rotate(-90, 14, %.1f)">%s</text>',
			yc, grey, yc, esc(primaryLayer.spec.yTitle))
	end
	if rightYLayer and rightYLayer.spec.yTitle then
		local rx = padL + plotW + rightAxisW - 8
		local yc = padT + plotH / 2
		buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s" transform="rotate(90, %.1f, %.1f)">%s</text>',
			rx, yc, grey, rx, yc, esc(rightYLayer.spec.yTitle))
	end

	-- 7. 繪製整合圖例
	if showAnyLegend and #legendEntries > 0 then
		local legX = padL + plotW + rightAxisW + 6
		local legY = padT + 4
		if legendTitle then
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s">%s</text>',
				legX, legY + 10, grey, esc(legendTitle))
			legY = legY + 20
		end
		for idx, ent in ipairs(legendEntries) do
			local iy = legY + (idx - 1) * 18
			if ent.chartType == "line" then
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="2.5"/>', legX, iy + 5.5, legX + 14, iy + 5.5, ent.color)
			else
				buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s"/>', legX, iy, ent.color)
			end
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-family="sans-serif" font-size="11px" fill="%s">%s</text>',
				legX + 18, iy + 9.5, grey, esc(ent.title))
		end
	end
	TrackingCategory.append('有SVG圖表的頁面')
	return mw.svg.new()
		:setAttribute('viewBox', format('0 0 %d %d', ceil(totalW), ceil(totalH)))
		:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
		:setImgAttribute('width', tostring(ceil(totalW)))
		:setImgAttribute('height', tostring(ceil(totalH)))
		:setImgAttribute('class', 'graph-multichart-svg')
		:setContent(table.concat(buf))
		:toImage()
end

return p