跳转到内容

模組:Graph/SVGRenderer

被永久保护的模块
维基百科,自由的百科全书
-- Module:Graph/SVGRenderer
local p = {}
local TrackingCategory = require('Module:TrackingCategory')
local Utils = require('Module:Graph/RenderUtils')

local format, sub, gsub, match = string.format, string.sub, string.gsub, string.match
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 buildPathD = Utils.buildPathD
local renderSymbol = Utils.renderSymbol
local finalizeSvgWithOverlay = Utils.finalizeSvgWithOverlay
local buildPointHit = Utils.buildPointHit
local buildRectHit = Utils.buildRectHit
local FlushImgOverlays = Utils.FlushImgOverlays

-- 匯出共用介面供 Module:Graph/MapRenderer 等外部模組呼叫
p.renderSymbol = renderSymbol
p.finalizeSvgWithOverlay = finalizeSvgWithOverlay

-- 短路直接輸出原生 <svg> 字串
function p.RenderSVG(frame, kvargs)
	local cargs = type(frame)==type({}) and (frame[1]or frame.content or frame.code) and frame or nil
	local call_by_module = not(not(cargs or not (type(frame)==type({}) and frame.getParent and type(frame.getParent)==type(function()end))))
	local raise = function(msg) if call_by_module then error(msg) else return "<strong class='error'>"..msg.."</strong>" end end
	local rargs = type(frame)==type({}) and frame.args or (type(cargs)==type({}) and cargs) or {}
	if type(frame)==type("string") then rargs = { [1] = frame } end --從其他模組呼叫
	local parent = type(frame)==type({}) and frame.getParent and type(frame.getParent)==type(function()end) and frame:getParent() or {}
	local pargs = type(parent)==type({}) and parent.args or (type(kvargs)==type({}) and kvargs) or {}
	local args = {}
	for rk, rv in pairs(rargs) do args[rk] = rv end
	-- 支援從母模板傳遞參數
	for pk, pv in pairs(pargs) do args[pk] = pv or rargs[pk] or args[pk] end

	-- 取得 SVG 原始碼 (支援使用命名的 code= 或是匿名參數 1=)
	local raw = args and (args[1] or args.content or args.code) or ''
    local svg_code = mw.text.unstripNoWiki(tostring(raw))
    if not svg_code or mw.text.trim(svg_code) == '' then
        return raise("請提供 SVG 原始碼 (code 參數)")
    end

    -- 1. 解析 SVG 結構
    -- 利用貪婪匹配 (.*) 確保能一路抓到最後一個 </svg>,完美支援內部嵌套的 <svg>
    -- 由於 SVG 原始碼都為 ASCII 結構,使用原生 string.match 效能最高
    local open_tag_content, inner_html = string.match(svg_code, "<[sS][vV][gG]%s*([^>]*)>(.*)</[sS][vV][gG]>")
	if not inner_html or mw.text.trim(inner_html) == '' then
        return raise("請提供 SVG 原始碼 (code 參數)")
    end
    if not open_tag_content then
        return raise("無法解析 SVG 格式,請確認原始碼包含完整的 &lt;svg&gt; 與 &lt;/svg&gt; 標籤。")
    end

    -- 2. 實例化 mw.svg
    local svg_obj = mw.svg.new()

    -- 3. 自動提取並套用 <svg> 的屬性
    -- 匹配模式說明:([%w%-%:]+) 匹配屬性名,(["\']) 匹配引號,(.-) 非貪婪匹配內容
    for key, quote, value in string.gmatch(open_tag_content, '([%w%-%:]+)%s*=%s*(["\'])(.-)%2') do
        local lower_key = string.lower(key)
        -- 自動過濾掉 javascript: 以及 onload= 等事件處理,確保符合維基百科安全規範
        if not string.match(lower_key, "^on") then
            svg_obj:setAttribute(key, value)
        end
    end

    -- 4. 填入內部 DOM
    svg_obj:setContent(inner_html)

    -- 5. 設定維基百科輸出的 <img> 標籤屬性
    svg_obj:setImgAttribute('width', '100%')
    svg_obj:setImgAttribute('height', '100%') -- 讓高度也能跟隨外部容器自適應
    if args.img_class then svg_obj:setImgAttribute('class', args.img_class) end
    if args.alt then svg_obj:setImgAttribute('alt', args.alt) end

    -- 產生 SVG Strip Marker
    local img_marker = svg_obj:toImage()

    -- 6. 建立外部 HTML 包裝容器
    -- 讓用戶可透過 wiki 代碼的 CSS 控制圖片大小與排版
    local wrapper_tag = (args.inline == 'yes') and 'span' or 'div'
    local wrapper = mw.html.create(wrapper_tag)
    
    if args.inline == 'yes' then 
        wrapper:css('display', 'inline-block') 
    end
    
    if args.class then wrapper:addClass(args.class) end
    if args.style then wrapper:cssText(args.style) end
    if args.width then wrapper:css('width', args.width) end
    if args.height then wrapper:css('height', args.height) end
    
    -- 支援簡易的對齊參數
    if args.align == 'center' then
        wrapper:css('margin', '0 auto'):css('display', 'block')
    elseif args.align == 'left' then
        wrapper:css('float', 'left')
    elseif args.align == 'right' then
        wrapper:css('float', 'right')
    end

    -- 置入輸出的圖片
    wrapper:wikitext(img_marker)

    return tostring(wrapper)
end

-- 3D 空間圖表渲染器 (支援 bar3d, scatter3d, surface3d, line3d;按需延遲載入 MathEngine)
local function render3DChart(spec)
	local MathEngine = require('Module:Graph/MathEngine')
	local plotW = max(180, spec.width or 360)
	local plotH = max(160, spec.height or 260)
	local chartType = spec.chartType or "scatter3d"
	local grey = spec.persistentGrey or "#54595d"

	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

	local hasExplicitZ = (type(zSeries) == "table" and #zSeries > 0)

	-- 1. 解析 X 軸 (支援數值或類別)
	local isOrdinalX = (spec.xType == "string")
	local xNums = {}
	local dataXMin, dataXMax = math.huge, -math.huge
	for j = 1, nPoints do
		local xv = not isOrdinalX and tonumber(xArr[j]) or nil
		if xv == nil and xArr[j] ~= nil and xArr[j] ~= false then
			isOrdinalX = true
		end
		xNums[j] = xv
		if xv then
			if xv < dataXMin then dataXMin = xv end
			if xv > dataXMax then dataXMax = xv end
		end
	end
	if isOrdinalX then
		for j = 1, nPoints do xNums[j] = j end
		dataXMin, dataXMax = 1, max(1, nPoints)
	elseif dataXMin == math.huge then
		dataXMin, dataXMax = 0, max(1, nPoints)
	end

	-- 2. 解析 Y 軸與垂直高度 Z 軸
	local isOrdinalY = not hasExplicitZ
	local dataYMin, dataYMax = math.huge, -math.huge
	local dataZMin, dataZMax = math.huge, -math.huge
	if chartType == "bar3d" then dataZMin = 0 end

	if hasExplicitZ then
		for i = 1, nSeries do
			local yArr = ySeries[i] or {}
			local zArr = zSeries[i] or zSeries[1] or {}
			for j = 1, nPoints do
				local yv = tonumber(yArr[j])
				local zv = tonumber(zArr[j])
				if yv then
					if yv < dataYMin then dataYMin = yv end
					if yv > dataYMax then dataYMax = yv end
				end
				if zv then
					if zv < dataZMin then dataZMin = zv end
					if zv > dataZMax then dataZMax = zv end
				end
			end
		end
		if dataYMin == math.huge then
			isOrdinalY = true
			dataYMin, dataYMax = 1, max(1, nSeries)
		end
	else
		dataYMin, dataYMax = 1, max(1, nSeries)
		for i = 1, nSeries do
			local yArr = ySeries[i] or {}
			for j = 1, nPoints do
				local zv = tonumber(yArr[j])
				if zv then
					if zv < dataZMin then dataZMin = zv end
					if zv > dataZMax then dataZMax = zv end
				end
			end
		end
	end
	if dataZMin == math.huge then dataZMin, dataZMax = 0, 10 end

	-- 3. 計算三軸刻度與正規化範圍 [-1, 1]
	local isPctX = spec.xAxisFormat and (string.find(spec.xAxisFormat, "%", 1, true) ~= nil or string.find(spec.xAxisFormat, "‰", 1, true) ~= nil)
	local isPctY = spec.yAxisFormat and (string.find(spec.yAxisFormat, "%", 1, true) ~= nil or string.find(spec.yAxisFormat, "‰", 1, true) ~= nil)
	local xTicks, xMin, xMax = {}, tonumber(spec.xMin) or dataXMin, tonumber(spec.xMax) or dataXMax
	if not isOrdinalX then
		local niceMin, niceMax
		xTicks, niceMin, niceMax = computeNiceTicks(xMin, xMax, 4, spec.xScaleType, (spec.xType == "integer") and not isPctX)
		if not spec.xMin then xMin = niceMin end
		if not spec.xMax then xMax = niceMax end
	else
		xMin, xMax = 0.5, max(1, nPoints) + 0.5
	end
	if xMax <= xMin then xMax = xMin + 1 end

	local yTicks, yMin, yMax = {}, tonumber(spec.yMin) or dataYMin, tonumber(spec.yMax) or dataYMax
	if not isOrdinalY then
		local niceMin, niceMax
		yTicks, niceMin, niceMax = computeNiceTicks(yMin, yMax, 4, spec.yScaleType, (spec.yType == "integer") and not isPctY)
		if not spec.yMin then yMin = niceMin end
		if not spec.yMax then yMax = niceMax end
	else
		yMin, yMax = 0.5, max(1, nSeries) + 0.5
	end
	if yMax <= yMin then yMax = yMin + 1 end

	local zMin = tonumber(spec.zMin) or (not hasExplicitZ and tonumber(spec.yMin)) or dataZMin
	local zMax = tonumber(spec.zMax) or (not hasExplicitZ and tonumber(spec.yMax)) or dataZMax
	local zScaleType = spec.zScaleType or (not hasExplicitZ and spec.yScaleType) or "linear"
	local zFormat = spec.zAxisFormat or (not hasExplicitZ and spec.yAxisFormat)
	local isPctZ = zFormat and (string.find(zFormat, "%", 1, true) ~= nil or string.find(zFormat, "‰", 1, true) ~= nil)
	local zIsInt = ((spec.zType == "integer") or (not hasExplicitZ and spec.yType == "integer")) and not isPctZ
	local zTicks, niceZMin, niceZMax = computeNiceTicks(zMin, zMax, 4, zScaleType, zIsInt)
	if not spec.zMin and not (not hasExplicitZ and spec.yMin) then zMin = niceZMin end
	if not spec.zMax and not (not hasExplicitZ and spec.yMax) then zMax = niceZMax end
	if zMax <= zMin then zMax = zMin + 1 end

	if isPctZ and (dataZMin < -1e-9 or dataZMax > 1 + 1e-9 or zMin < -1e-9 or zMax > 1 + 1e-9) then
		for _, bVal in ipairs({ 0, 1 }) do
			if bVal >= zMin - 1e-9 and bVal <= zMax + 1e-9 then
				local exists = false
				for _, tk in ipairs(zTicks) do
					if abs(tk - bVal) < 1e-6 then exists = true; break end
				end
				if not exists then zTicks[#zTicks + 1] = bVal end
			end
		end
		table.sort(zTicks)
	end

	local function normX(v) return 2 * applyScale(v, xMin, xMax, spec.xScaleType) - 1 end
	local function normY(v) return 2 * applyScale(v, yMin, yMax, spec.yScaleType) - 1 end
	local function normZ(v) return 2 * applyScale(v, zMin, zMax, zScaleType) - 1 end

	local xTickNorms, yTickNorms, zTickNorms = {}, {}, {}
	if isOrdinalX then
		local step = max(1, ceil(nPoints / 6))
		for j = 1, nPoints, step do
			xTickNorms[#xTickNorms + 1] = { norm = normX(j), label = tostring(xArr[j] or j) }
		end
	else
		for _, tk in ipairs(xTicks) do
			if tk >= xMin - 1e-9 and tk <= xMax + 1e-9 then
				xTickNorms[#xTickNorms + 1] = { norm = normX(tk), label = formatNumber(tk, spec.xAxisFormat, spec.xType == "integer") }
			end
		end
	end

	if isOrdinalY then
		local step = max(1, ceil(nSeries / 6))
		for i = 1, nSeries, step do
			yTickNorms[#yTickNorms + 1] = { norm = normY(i), label = tostring(spec.seriesTitles[i] or ("S" .. i)) }
		end
	else
		for _, tk in ipairs(yTicks) do
			if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
				yTickNorms[#yTickNorms + 1] = { norm = normY(tk), label = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer") }
			end
		end
	end

	for _, tk in ipairs(zTicks) do
		if tk >= zMin - 1e-9 and tk <= zMax + 1e-9 then
			zTickNorms[#zTickNorms + 1] = { norm = normZ(tk), label = formatNumber(tk, zFormat, zIsInt) }
		end
	end

	-- 4. 配置畫布邊距與自動校準 3D 透視相機
	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
		legW = min(200, max(80, legW))
	end

	local padL, padR = 38, 28 + legW
	local padT = (spec.title and 32 or 16)
	local padB = 28
	local totalW = padL + plotW + padR
	local totalH = padT + plotH + padB

	local cam = MathEngine.createCamera({
		theta = spec.viewTheta or 45,
		phi = spec.viewPhi or 28,
		distance = spec.viewDistance or 5.5
	})
	cam.fitToViewport(padL + 12, padT + 10, plotW - 24, plotH - 20)

	local stage = MathEngine.buildBoxStage(cam, xTickNorms, yTickNorms, zTickNorms)
	local customState = { defs = {}, map = {}, count = 0 }
	local buf = { "" }

	-- 5. 繪製開放式底板網格、外伸刻度線與三軸主框線
	if not spec.hideAxes then
		for _, g in ipairs(stage.grids) do
			buf[#buf + 1] = format('<line class="g3-grid" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>', g.x1, g.y1, g.x2, g.y2)
		end
		for _, tk in ipairs(stage.tickLines) do
			buf[#buf + 1] = format('<line class="g3-tick" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>', tk.x1, tk.y1, tk.x2, tk.y2)
		end
		for _, ax in ipairs(stage.axesLines) do
			buf[#buf + 1] = format('<line class="g3-axis" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>', ax.x1, ax.y1, ax.x2, ax.y2)
		end

		for _, a in ipairs(stage.xAnchors) do
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', a.sx, a.sy, grey, esc(a.label))
		end
		for _, a in ipairs(stage.yAnchors) do
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', a.sx, a.sy, grey, esc(a.label))
		end
		for _, a in ipairs(stage.zAnchors) do
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-size="11px" fill="%s">%s</text>', a.sx, a.sy, grey, esc(a.label))
		end

		local xTitleStr = spec.xTitle or "x"
		local yTitleStr = spec.yTitle or (hasExplicitZ and (spec.yTitle or "y") or "y")
		local zTitleStr = spec.zTitle or (not hasExplicitZ and spec.yTitle) or "z"
		buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
			stage.titleAnchors.x.sx, stage.titleAnchors.x.sy, grey, esc(xTitleStr))
		buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
			stage.titleAnchors.y.sx, stage.titleAnchors.y.sy, grey, esc(yTitleStr))
		buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
			stage.titleAnchors.z.sx, stage.titleAnchors.z.sy, grey, esc(zTitleStr))
	end

	-- 6. 收集 3D 幾何圖元並進行畫家演算法 (Z-Sorting)
	local drawList = {}
	local valTextBuf = {}
	local tipBuf = {}
	local enableTips = (spec.interactive ~= false) and (nSeries * nPoints <= 250)
	local baseZVal = max(zMin, min(zMax, 0))
	local wzBase = normZ(baseZVal)
	local orderCounter = 0

	if chartType == "bar3d" then
		local dx = min(0.22, (1.6 / max(1, nPoints)) * 0.36)
		local dy = min(0.22, (1.6 / max(1, nSeries)) * 0.36)

		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 alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 0.95
			local yArr = ySeries[i] or {}
			local zArr = hasExplicitZ and (zSeries[i] or zSeries[1] or {}) or yArr
			local sTitle = spec.seriesTitles[i] or ("S" .. i)

			for j = 1, nPoints do
				local xv = xNums[j]
				local yv = hasExplicitZ and tonumber(yArr[j]) or i
				local zv = tonumber(zArr[j])
				if xv and yv and zv then
					orderCounter = orderCounter + 1
					local wx, wy, wz = normX(xv), normY(yv), normZ(zv)
					local col = hasSeriesCmap and MathEngine.sampleColormap((wz + 1) * 0.5, sCmap) or baseCol
					local faces, topPt = MathEngine.buildCuboidFaces(cam, wx - dx, wx + dx, wy - dy, wy + dy, wzBase, wz, col, alpha, orderCounter)
					for f = 1, #faces do drawList[#drawList + 1] = faces[f] end

					local zStr = formatNumber(zv, zFormat, zIsInt)
					if spec.showValues then
						local sv = spec.showValues
						local fSize = tonumber(sv.fontsize) or 10
						local fColor = sv.fontcolor or grey
						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>',
							topPt.sx, topPt.sy - 5, fSize, fColor, esc(zStr))
					end
					if enableTips then
						local xLbl = isOrdinalX and tostring(xArr[j] or j) or formatNumber(xv, spec.xAxisFormat, spec.xType == "integer")
						local yLbl = isOrdinalY and sTitle or formatNumber(yv, spec.yAxisFormat, spec.yType == "integer")
						local tipHtml = format('<b>%s</b> (%s, %s): %s', esc(sTitle), esc(xLbl), esc(yLbl), esc(zStr))
						tipBuf[#tipBuf + 1] = buildPointHit(topPt.sx, topPt.sy, totalW, totalH, tipHtml, col, 16)
					end
				end
			end
		end

	elseif chartType == "surface3d" then
		-- 建構 (X, Y) 曲面網格並利用雙向切向量法向量與科學色階著色
		local grid = {}
		local rCount, cCount = nSeries, nPoints
		if hasExplicitZ and nSeries == 1 then
			-- 若為單一數列 (x, y, z) 點集,自動將不重複 X 與 Y 歸納為二維網格
			local uxSet, uySet, uxList, uyList = {}, {}, {}, {}
			local yArr, zArr = ySeries[1] or {}, zSeries[1] or {}
			for j = 1, nPoints do
				local xv, yv, zv = xNums[j], tonumber(yArr[j]), tonumber(zArr[j])
				if xv and yv and zv then
					if not uxSet[xv] then uxSet[xv] = true; uxList[#uxList + 1] = xv end
					if not uySet[yv] then uySet[yv] = true; uyList[#uyList + 1] = yv end
				end
			end
			table.sort(uxList); table.sort(uyList)
			local xIdxMap, yIdxMap = {}, {}
			for idx, v in ipairs(uxList) do xIdxMap[v] = idx end
			for idx, v in ipairs(uyList) do yIdxMap[v] = idx end
			rCount, cCount = #uyList, #uxList
			for r = 1, rCount do grid[r] = {} end
			for j = 1, nPoints do
				local xv, yv, zv = xNums[j], tonumber(yArr[j]), tonumber(zArr[j])
				if xv and yv and zv and xIdxMap[xv] and yIdxMap[yv] then
					grid[yIdxMap[yv]][xIdxMap[xv]] = { normX(xv), normY(yv), normZ(zv), zv, xv, yv }
				end
			end
		else
			for i = 1, nSeries do
				grid[i] = {}
				local yArr = ySeries[i] or {}
				local zArr = hasExplicitZ and (zSeries[i] or zSeries[1] or {}) or yArr
				for j = 1, nPoints do
					local xv = xNums[j]
					local yv = hasExplicitZ and tonumber(yArr[j]) or i
					local zv = tonumber(zArr[j])
					if xv and yv and zv then
						grid[i][j] = { normX(xv), normY(yv), normZ(zv), zv, xv, yv }
					end
				end
			end
		end

		local cmapName = spec.colorMap or "viridis"
		for r = 1, rCount - 1 do
			for c = 1, cCount - 1 do
				local g1 = grid[r] and grid[r][c]
				local g2 = grid[r] and grid[r][c + 1]
				local g3 = grid[r + 1] and grid[r + 1][c + 1]
				local g4 = grid[r + 1] and grid[r + 1][c]
				if g1 and g2 and g3 and g4 then
					orderCounter = orderCounter + 1
					local avgNormZ = (g1[3] + g2[3] + g3[3] + g4[3]) * 0.25
					local tCol = (avgNormZ + 1) * 0.5
					local baseCol = MathEngine.sampleColormap(tCol, cmapName)
					local qFace = MathEngine.buildQuadFace(cam, g1, g2, g3, g4, baseCol, 0.94, orderCounter)
					drawList[#drawList + 1] = qFace
					if enableTips and (#tipBuf < 180) then
						local avgRawZ = (g1[4] + g2[4] + g3[4] + g4[4]) * 0.25
						local tipHtml = format('<b>z</b>: %s', esc(formatNumber(avgRawZ, zFormat, zIsInt)))
						tipBuf[#tipBuf + 1] = buildPointHit(qFace.cx, qFace.cy, totalW, totalH, tipHtml, qFace.fill, 12)
					end
				end
			end
		end

	else
		-- scatter3d 與 line3d
		local sizeMin, sizeMax = math.huge, -math.huge
		if spec.sizes then
			for i = 1, nSeries do
				local sArr = spec.sizes[i]
				if sArr then
					for j = 1, nPoints do
						local sv = tonumber(sArr[j])
						if sv then
							if sv < sizeMin then sizeMin = sv end
							if sv > sizeMax then sizeMax = sv end
						end
					end
				end
			end
		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 alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 0.9
			local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or 2.0
			local yArr = ySeries[i] or {}
			local zArr = hasExplicitZ and (zSeries[i] or zSeries[1] or {}) or yArr
			local sTitle = spec.seriesTitles[i] or ("y" .. i)
			local prevPt = nil

			for j = 1, nPoints do
				local xv = xNums[j]
				local yv = hasExplicitZ and tonumber(yArr[j]) or i
				local zv = tonumber(zArr[j])
				if xv and yv and zv then
					orderCounter = orderCounter + 1
					local wx, wy, wz = normX(xv), normY(yv), normZ(zv)
					local pt = cam.project(wx, wy, wz)
					local floorPt = cam.project(wx, wy, -1)
					local col = hasSeriesCmap and MathEngine.sampleColormap((wz + 1) * 0.5, sCmap) or baseCol

					if chartType == "line3d" and prevPt and lw > 0 then
						drawList[#drawList + 1] = {
							kind = "line",
							depth = (prevPt.depth + pt.depth) * 0.5,
							order = orderCounter,
							x1 = prevPt.sx, y1 = prevPt.sy, x2 = pt.sx, y2 = pt.sy,
							stroke = col, strokeWidth = lw, alpha = alpha
						}
					else
						-- 繪製散佈點至底板的投影輔助垂線,幫助讀者判讀三維空間位置
						drawList[#drawList + 1] = {
							kind = "stem",
							depth = pt.depth - 0.02,
							order = orderCounter,
							x1 = floorPt.sx, y1 = floorPt.sy, x2 = pt.sx, y2 = pt.sy,
							stroke = col
						}
					end
					prevPt = pt

					local dynSize = spec.sizes and ((spec.sizes[i] and tonumber(spec.sizes[i][j])) or (spec.sizes[1] and tonumber(spec.sizes[1][j]))) or nil
					local userR = (spec.symbols and (spec.symbols.radii[i] or spec.symbols.radii[1])) or (chartType == "line3d" and 3.0 or 4.5)
					local baseR = userR
					if dynSize then
						local scaleMul = userR / 4.5
						if sizeMax > sizeMin then
							baseR = (3.5 + sqrt(max(0, (dynSize - sizeMin) / (sizeMax - sizeMin))) * 11.5) * scaleMul
						else
							baseR = max(userR, sqrt(max(1, abs(dynSize))) * 0.75 * scaleMul)
						end
					end
					local rProj = max(2.0, baseR * pt.pScale)
					local shp = (spec.symbols and (spec.symbols.shapes[i] or spec.symbols.shapes[1])) or "circle"
					local fillCol = (spec.symbols and spec.symbols.noFill) and "none" or MathEngine.shadeColor(col, 1.06)
					local strokeCol = MathEngine.shadeColor(col, 0.72)

					if chartType == "scatter3d" or (spec.symbols and spec.symbols.enabled) or dynSize then
						drawList[#drawList + 1] = {
							kind = "sym",
							depth = pt.depth,
							order = orderCounter + 0.5,
							shape = shp, cx = pt.sx, cy = pt.sy, r = rProj,
							fill = fillCol, stroke = strokeCol, strokeW = 1.0, alpha = alpha
						}
					end

					local zStr = formatNumber(zv, zFormat, zIsInt)
					if spec.showValues then
						local sv = spec.showValues
						local fSize = tonumber(sv.fontsize) or 10
						local fColor = sv.fontcolor or grey
						valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" fill="%s" data-halo="1">%s</text>',
							pt.sx, pt.sy - rProj - 4, fSize, fColor, esc(zStr))
					end
					if enableTips then
						local xLbl = isOrdinalX and tostring(xArr[j] or j) or formatNumber(xv, spec.xAxisFormat, spec.xType == "integer")
						local yLbl = isOrdinalY and sTitle or formatNumber(yv, spec.yAxisFormat, spec.yType == "integer")
						local tipHtml = format('<b>%s</b> (%s, %s, %s)', esc(sTitle), esc(xLbl), esc(yLbl), esc(zStr))
						local swSpec = { shape = shp, fill = fillCol, stroke = strokeCol, strokeWidth = 1.0, r = rProj }
						tipBuf[#tipBuf + 1] = buildPointHit(pt.sx, pt.sy, totalW, totalH, tipHtml, col, max(14, rProj * 2 + 4), swSpec)
					end
				else
					prevPt = nil
				end
			end
		end
	end

	-- 7. 依深度由遠到近排序並輸出 SVG 圖元
	MathEngine.depthSort(drawList)

	for _, item in ipairs(drawList) do
		if item.kind == "poly" then
			local opAttr = (item.alpha and item.alpha < 1) and format(' fill-opacity="%.2f"', item.alpha) or ""
			buf[#buf + 1] = format('<polygon points="%s" fill="%s" stroke="%s" stroke-width="%.2f" stroke-linejoin="round"%s/>',
				item.points, item.fill, item.stroke, item.strokeWidth or 0.6, opAttr)
		elseif item.kind == "line" then
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f" stroke-linecap="round"/>',
				item.x1, item.y1, item.x2, item.y2, item.stroke, item.strokeWidth)
		elseif item.kind == "stem" then
			buf[#buf + 1] = format('<line class="g3-stem" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s"/>',
				item.x1, item.y1, item.x2, item.y2, item.stroke)
		elseif item.kind == "sym" then
			buf[#buf + 1] = renderSymbol(item.shape, item.cx, item.cy, item.r, item.fill, item.stroke, item.strokeW, item.alpha, customState)
		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>',
			padL + plotW / 2, grey, esc(spec.title))
	end

	if spec.showLegend and nSeries > 0 then
		local legX = padL + plotW + 6
		local legY = padT + 8
		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-3d-leg-cmap-" .. i
				customState.defs[#customState.defs + 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"/>', 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

	local svgStyle = '<style>.g3-grid{stroke:#e1e3e5;stroke-width:0.85}.g3-tick{stroke:#333;stroke-width:1.1}.g3-axis{stroke:#222;stroke-width:1.4;stroke-linecap:round}.g3-stem{stroke-width:0.85;stroke-dasharray:2,2;opacity:0.55}</style>'
	buf[1] = format('<defs>%s%s</defs>', svgStyle, table.concat(customState.defs))

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

-- 直角座標系與極座標圖表主渲染器
function p.renderChart(spec)
	if spec.is3D or spec.chartType == "scatter3d" or spec.chartType == "bar3d" or spec.chartType == "surface3d" or spec.chartType == "line3d" then
		return render3DChart(spec)
	end
	local renderer = require('Module:Graph/ChartRenderer')
	if spec.rangedRect then return renderer.renderRangedRectChart(spec) end
	if spec.boxPlot or spec.chartType == "boxplot" then return renderer.renderBoxPlotChart(spec) end
	if spec.gantt or spec.chartType == "gantt" then return renderer.renderGanttChart(spec) end
	if spec.heatmap or spec.chartType == "heatmap" then return renderer.renderHeatmapChart(spec) end
	if spec.chartType == "polarbar" then return renderer.renderPolarBarChart(spec) end
	if spec.chartType == "radar" then return renderer.renderRadarChart(spec) end
	if spec.chartType == "pie" then return renderer.renderPieChart(spec) end

	local plotW = max(60, spec.width or 200)
	local plotH = max(50, spec.height or 200)
	local chartType = spec.chartType or "line"
	local stacked = spec.stacked
	local hideAxes = spec.hideAxes

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

	local zSeries = spec.z or {}
	local hasRectZ = (chartType == "rect") and (type(zSeries) == "table" and #zSeries > 0)
	local isContinuousRect = (chartType == "rect") and (hasRectZ or spec.isHist) and (spec.xType ~= "string")

	local isDateX = (spec.xType == "date")
	local isOrdinalX = ((chartType == "rect") and not isContinuousRect) or (spec.xType == "string")
	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)
		else
			xv = tonumber(rawX)
			if not xv and rawX ~= nil and rawX ~= false then
				local dv = parseDateValue(rawX)
				if dv then
					isDateX = true
					xv = dv
				else
					isOrdinalX = true
					isContinuousRect = false
					xv = j
				end
			end
		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

	-- 若為直角座標不等寬直方圖 / 連續直方圖,預先計算每一筆資料的 [x0, x1] 物理區間並擴展 X 軸上下界
	local rectDefaultW = 1
	local isEdgeRect = (spec.barAlign ~= "center")
	if isContinuousRect then
		if not hasRectZ and nPoints > 1 then
			local sortedX = {}
			for j = 1, nPoints do
				if xNums[j] then sortedX[#sortedX + 1] = xNums[j] end
			end
			table.sort(sortedX)
			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
			if minDiff < math.huge then rectDefaultW = minDiff end
		end
		for j = 1, nPoints do
			local xv = xNums[j]
			if xv then
				for i = 1, max(1, nSeries) do
					local zw = hasRectZ and ((zSeries[i] and tonumber(zSeries[i][j])) or (zSeries[1] and tonumber(zSeries[1][j]))) or rectDefaultW
					zw = abs(zw or rectDefaultW)
					local x0 = isEdgeRect and xv or (xv - zw * 0.5)
					local x1 = isEdgeRect and (xv + zw) or (xv + zw * 0.5)
					if x0 < dataXMin then dataXMin = x0 end
					if x1 > dataXMax then dataXMax = x1 end
				end
			end
		end
	end

	local stackedY0, stackedY1 = {}, {}
	local dataYMin, dataYMax = math.huge, -math.huge
	if stacked or chartType == "rect" or chartType == "area" then
		dataYMin = 0
	end

	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 rawV = ySeries[i][j]
			local v = (rawV ~= false and rawV ~= nil) and tonumber(rawV) or nil
			if v ~= nil then
				if stacked then
					if v >= 0 then
						stackedY0[i][j] = posSum
						posSum = posSum + v
						stackedY1[i][j] = posSum
					else
						stackedY0[i][j] = negSum
						negSum = negSum + v
						stackedY1[i][j] = negSum
					end
				else
					stackedY0[i][j] = 0
					stackedY1[i][j] = v
				end
				local yLo = stackedY0[i][j]
				local yHi = stackedY1[i][j]
				if yLo < dataYMin then dataYMin = yLo end
				if yHi < dataYMin then dataYMin = yHi end
				if yLo > dataYMax then dataYMax = yLo end
				if yHi > dataYMax then dataYMax = yHi end
			end
		end
	end

	if dataYMin == math.huge then dataYMin, dataYMax = 0, 10 end
	if spec.yScaleType == "log" and dataYMin <= 0 then dataYMin = 1 end

	local reqXMin = spec.xMin and (isDateX and parseDateValue(spec.xMin) or tonumber(spec.xMin)) or nil
	local reqXMax = spec.xMax and (isDateX and parseDateValue(spec.xMax) or tonumber(spec.xMax)) or nil
	local xMin = reqXMin or dataXMin
	local xMax = reqXMax or dataXMax
	local yMin = tonumber(spec.yMin) or dataYMin
	local yMax = tonumber(spec.yMax) or dataYMax

	local isPctX = spec.xAxisFormat and (string.find(spec.xAxisFormat, "%", 1, true) ~= nil or string.find(spec.xAxisFormat, "‰", 1, true) ~= nil)
	local isPctY = spec.yAxisFormat and (string.find(spec.yAxisFormat, "%", 1, true) ~= nil or string.find(spec.yAxisFormat, "‰", 1, true) ~= nil)
	local needPctBoundsY = isPctY and (dataYMin < -1e-9 or dataYMax > 1 + 1e-9 or yMin < -1e-9 or yMax > 1 + 1e-9)
	local needPctBoundsX = isPctX and (not isOrdinalX) and (not isDateX) and (dataXMin < -1e-9 or dataXMax > 1 + 1e-9 or xMin < -1e-9 or xMax > 1 + 1e-9)

	local xTicks, yTicks = {}, {}
	local gridRadius = nil
	if spec.isScatterGrid then
		xMin = dataXMin - 0.5
		xMax = dataXMax + 0.5
		yMin = dataYMin - 0.5
		yMax = dataYMax + 0.5
		gridRadius = max(plotW / max(1, xMax - xMin), plotH / max(1, yMax - yMin)) * 0.505
	else
		local niceYMin, niceYMax
		yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, max(3, floor(plotH / 36)), 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 needPctBoundsY then
			for _, bVal in ipairs({ 0, 1 }) do
				if bVal >= yMin - 1e-9 and bVal <= yMax + 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

		if not isOrdinalX then
			local niceXMin, niceXMax
			xTicks, niceXMin, niceXMax = computeNiceTicks(xMin, xMax, max(3, floor(plotW / 65)), spec.xScaleType, (spec.xType == "integer" or isDateX) and not isPctX)
			if not reqXMin and not isDateX then xMin = niceXMin end
			if not reqXMax and not isDateX then xMax = niceXMax end

			if needPctBoundsX then
				for _, bVal in ipairs({ 0, 1 }) do
					if bVal >= xMin - 1e-9 and bVal <= xMax + 1e-9 then
						local exists = false
						for _, tk in ipairs(xTicks) do
							if abs(tk - bVal) < 1e-6 then exists = true; break end
						end
						if not exists then xTicks[#xTicks + 1] = bVal end
					end
				end
				table.sort(xTicks)
			end
		end
	end
	if xMax <= xMin then xMax = xMin + 1 end
	if yMax <= yMin then yMax = yMin + 1 end

	local xAngle = tonumber(spec.xAxisAngle) or 0
	local padL, padR, padT, padB = 0, 0, 0, 0
	if not hideAxes then
		local maxYLblLen = 3
		for _, tk in ipairs(yTicks) do
			local s = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer")
			maxYLblLen = max(maxYLblLen, mw.ustring.len(s))
		end
		padL = max(36, maxYLblLen * 7 + 14) + (spec.yTitle and 18 or 0)
		padT = (spec.title and 30 or 12)
		local extraAngleH = (abs(xAngle) > 15) and 24 or 0
		padB = 26 + extraAngleH + (spec.xTitle and 18 or 0)
		padR = 16
	elseif spec.isScatterGrid then
		local qz = max(8, floor(min(plotW, plotH) * 0.06))
		padL, padR, padT, padB = qz, qz, qz + (spec.title and 24 or 0), qz
	end

	if spec.showLegend and nSeries > 0 then
		local legW = 70
		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 + 20)
		end
		padR = padR + min(220, legW)
	end

	local totalW = padL + plotW + padR
	local totalH = max(padT + plotH + padB, padT + (spec.showLegend and (nSeries * 18 + 28) or 0) + padB)

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

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

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

	if spec.isScatterGrid then
		buf[#buf + 1] = format('<rect x="0" y="0" width="%d" height="%d" fill="#ffffff"/>', ceil(totalW), ceil(totalH))
	end

	local grey = spec.persistentGrey or "#54595d"

	if not hideAxes then
		local xTickPxList = {}
		if isOrdinalX then
			local step = max(1, ceil(nPoints / max(2, floor(plotW / (abs(xAngle) > 20 and 18 or 42)))))
			for j = 1, nPoints, step do
				xTickPxList[#xTickPxList + 1] = { j = j, px = mapX(j, j), lbl = tostring(xArr[j] or "") }
			end
		else
			for _, tk in ipairs(xTicks) do
				if tk >= xMin - 1e-9 and tk <= xMax + 1e-9 then
					local lbl = isDateX and formatDateValue(tk, spec.xAxisFormat) or formatNumber(tk, spec.xAxisFormat, spec.xType == "integer")
					xTickPxList[#xTickPxList + 1] = { tk = tk, px = mapX(tk, 1), lbl = lbl }
				end
			end
		end

		for _, tk in ipairs(yTicks) do
			if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
				local py = mapY(tk)
				if 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, spec.yAxisFormat, spec.yType == "integer")
				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(lbl))
			end
		end

		for _, xt in ipairs(xTickPxList) do
			local px = xt.px
			if 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 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-size="11px" fill="%s" transform="rotate(%d, %.2f, %.2f)">%s</text>',
					px, ly - 3, anchor, grey, xAngle, px, ly - 3, esc(xt.lbl))
			else
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', px, ly, grey, esc(xt.lbl))
			end
		end

		-- 當 Y 軸為百分比/千分比且數值超出 [0, 1] 時,在 0% (0) 與 100% (1) 繪製帶刻度的明顯基準橫線
		if needPctBoundsY then
			for _, bVal in ipairs({ 0, 1 }) do
				if bVal >= yMin - 1e-9 and bVal <= yMax + 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 _, xt in ipairs(xTickPxList) do
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" opacity="0.85"/>',
								xt.px, py - 3.5, xt.px, py + 3.5, grey)
						end
					end
				end
			end
		end

		-- 當 X 軸為百分比/千分比且數值超出 [0, 1] 時,在 0% (0) 與 100% (1) 繪製帶刻度的明顯基準直線
		if needPctBoundsX then
			for _, bVal in ipairs({ 0, 1 }) do
				if bVal >= xMin - 1e-9 and bVal <= xMax + 1e-9 then
					local px = mapX(bVal, 1)
					if abs(px - padL) > 1.5 then
						buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1.4" opacity="0.85"/>',
							px, padT, px, padT + plotH, grey)
						for _, tk in ipairs(yTicks) do
							if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
								local py = mapY(tk)
								buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" opacity="0.85"/>',
									px - 3.5, py, px + 3.5, py, grey)
							end
						end
					end
				end
			end
		end
	end

	buf[#buf + 1] = format('<g clip-path="url(#gr-clip)"%s>', spec.isScatterGrid and ' shape-rendering="crispEdges"' or '')
	local baseY0 = mapY(max(yMin, min(yMax, 0)))
	local valTextBuf = {}
	local symBuf = {}
	local tipBuf = {}
	local enableTips = (spec.interactive ~= false) and (not spec.isScatterGrid)
	local MAX_TIPS = 100
	local isScatterOrBubble = (chartType == "line") and ((tonumber(spec.linewidth) == 0) or (spec.sizes ~= nil))
	local totalValidPts = 0
	if enableTips then
		for i = 1, nSeries do
			for j = 1, nPoints do
				if stackedY1[i][j] ~= nil and (chartType == "rect" or xNums[j] ~= nil) then
					totalValidPts = totalValidPts + 1
				end
			end
		end
	end
	local tipStep = (totalValidPts > MAX_TIPS) and (totalValidPts / MAX_TIPS) or 1
	local randomTipSet = nil
	if enableTips and totalValidPts > MAX_TIPS and isScatterOrBubble then
		randomTipSet = {}
		local pool = {}
		for k = 1, totalValidPts do pool[k] = k end
		for k = 1, MAX_TIPS do
			local r = math.random(k, totalValidPts)
			pool[k], pool[r] = pool[r], pool[k]
			randomTipSet[pool[k]] = true
		end
	end
	local globalPtIdx = 0

	local function getXLabel(j, xv)
		if isOrdinalX then return tostring(xArr[j] or j) end
		if isDateX then return tostring(xArr[j] or formatDateValue(xv, spec.xAxisFormat)) end
		return formatNumber(xv, spec.xAxisFormat, spec.xType == "integer")
	end

	local sizeMin, sizeMax = math.huge, -math.huge
	if spec.sizes then
		for i = 1, nSeries do
			local sArr = spec.sizes[i]
			if sArr then
				for j = 1, nPoints do
					local sv = tonumber(sArr[j])
					if sv then
						if sv < sizeMin then sizeMin = sv end
						if sv > sizeMax then sizeMax = sv end
					end
				end
			end
		end
	end

	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 lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or (chartType == "area" and 1.5 or 2.5)
		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 seriesGradUrl = nil
		if hasSeriesCmap and chartType ~= "rect" then
			local gradId = "gr-ser-cmap-" .. i
			-- mapY(cMin) 對應圖表底部 (Y像素較大),mapY(cMax) 對應圖表頂部 (Y像素較小)
			customState.defs[#customState.defs + 1] = Utils.buildColormapGradientDef(gradId, sCmap, 0, mapY(cMin), 0, mapY(cMax), true)
			seriesGradUrl = "url(#" .. gradId .. ")"
		end
		local seriesPtIdx = 0
		local seriesTipK = 0
		local seriesPhase = (i - 1) % tipStep
		local nextSeriesTipIdx = (totalValidPts > MAX_TIPS) and (floor(seriesPhase + 1e-9) + 1) or 1

		local function shouldEmitTip()
			seriesPtIdx = seriesPtIdx + 1
			globalPtIdx = globalPtIdx + 1
			if not enableTips or #tipBuf >= MAX_TIPS then return false end
			if totalValidPts <= MAX_TIPS then return true end
			if isScatterOrBubble then
				return (randomTipSet and randomTipSet[globalPtIdx]) or false
			end
			if seriesPtIdx == nextSeriesTipIdx then
				seriesTipK = seriesTipK + 1
				nextSeriesTipIdx = floor(seriesTipK * tipStep + seriesPhase + 1e-9) + 1
				return true
			end
			return false
		end

		if chartType == "rect" then
			local defaultPad = isContinuousRect and 0.0 or 0.18
			local padRatio = max(0, min(0.85, tonumber(spec.barPadding) or defaultPad))
			local bandW = (plotW / max(1, nPoints)) * (1 - padRatio)
			local gap = (not stacked and nSeries > 1 and not isContinuousRect) and 1.5 or 0
			local subBarW = stacked and bandW or max(1.5, (bandW - (nSeries - 1) * gap) / nSeries)
			local opAttr = (alpha < 1) and format(' fill-opacity="%.2f"', alpha) or ""
			local strokeAttr = isContinuousRect and format(' stroke="#333333" stroke-width="%.2f"', tonumber(spec.linewidth) or 1.0) or ""

			for j = 1, nPoints do
				local y1v = stackedY1[i][j]
				local y0v = stackedY0[i][j]
				if y1v ~= nil and (not isContinuousRect or xNums[j] ~= nil) then
					local rawVal = tonumber(ySeries[i][j]) or y1v
					local barCol = hasSeriesCmap and Utils.sampleColormap((rawVal - cMin) / (cMax - cMin), sCmap) or baseCol
					local barX, curBarW, xTipLbl

					if isContinuousRect then
						local xv = xNums[j]
						local zw = hasRectZ and ((zSeries[i] and tonumber(zSeries[i][j])) or (zSeries[1] and tonumber(zSeries[1][j]))) or rectDefaultW
						zw = abs(zw or rectDefaultW)
						local rawX0 = isEdgeRect and xv or (xv - zw * 0.5)
						local rawX1 = isEdgeRect and (xv + zw) or (xv + zw * 0.5)
						local usableW = (rawX1 - rawX0) * (1 - padRatio)
						local midX = (rawX0 + rawX1) * 0.5
						local x0 = midX - usableW * 0.5
						local x1 = midX + usableW * 0.5
						local px0, px1 = mapX(x0, j), mapX(x1, j)
						local fullPxW = max(1.5, abs(px1 - px0))
						local leftPx = min(px0, px1)
						if not stacked and nSeries > 1 then
							local grpPad = max(0, min(0.6, tonumber(spec.groupPadding) or 0.0))
							local stepPx = fullPxW / nSeries
							curBarW = max(1.0, stepPx * (1 - grpPad))
							barX = leftPx + (i - 1) * stepPx + (stepPx - curBarW) * 0.5
						else
							barX = leftPx
							curBarW = fullPxW
						end
						if isEdgeRect then
							xTipLbl = format("%s ~ %s",
								formatNumber(rawX0, spec.xAxisFormat, spec.xType == "integer"),
								formatNumber(rawX1, spec.xAxisFormat, spec.xType == "integer"))
						else
							xTipLbl = format("%s (width: %s)",
								formatNumber(xv, spec.xAxisFormat, spec.xType == "integer"),
								formatNumber(zw, nil, false))
						end
					else
						local centerPx = mapX(j, j)
						barX = stacked and (centerPx - bandW / 2) or (centerPx - bandW / 2 + (i - 1) * (subBarW + gap))
						curBarW = subBarW
						xTipLbl = getXLabel(j, j)
					end

					local py1 = mapY(y1v)
					local py0 = stacked and mapY(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%s/>',
						barX, topY, curBarW, rectH, barCol, opAttr, strokeAttr)

					if spec.showValues and not stacked then
						local sv = spec.showValues
						local offset = tonumber(sv.offset) or -4
						local fSize = tonumber(sv.fontsize) or 10
						local fColor = sv.fontcolor or (offset >= 0 and "#ffffff" or grey)
						local txt = formatNumber(ySeries[i][j], sv.format, spec.yType == "integer")
						local tx = barX + curBarW / 2
						local ty = (offset >= 0) and (topY + offset + fSize) or (topY + offset)
						local haloAttr = (offset < 0) and ' data-halo="1"' or ''
						valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" fill="%s"%s>%s</text>',
							tx, ty, fSize, fColor, haloAttr, esc(txt))
					end

					if shouldEmitTip() then
						local vStr = formatNumber(ySeries[i][j], 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', esc(sTitle), esc(xTipLbl), esc(vStr))
							or format('<b>%s</b>: %s', esc(xTipLbl), esc(vStr))
						tipBuf[#tipBuf + 1] = buildRectHit(barX, topY, curBarW, rectH, totalW, totalH, tipHtml, barCol)
					end
				end
			end
		else
			local segments = {}
			local curSeg = {}
			for j = 1, nPoints do
				local xv = xNums[j]
				local y1v = stackedY1[i][j]
				local y0v = stackedY0[i][j] or 0
				if xv ~= nil and y1v ~= nil then
					local px, py1, py0 = mapX(xv, j), mapY(y1v), mapY(y0v)
					local rawNum = tonumber(ySeries[i][j]) or y1v
					local ptCol = hasSeriesCmap and Utils.sampleColormap((rawNum - cMin) / (cMax - cMin), sCmap) or baseCol
					curSeg[#curSeg + 1] = { px, py1, py0, ySeries[i][j] }

					local dynSizeVal = spec.sizes and ((spec.sizes[i] and tonumber(spec.sizes[i][j])) or (spec.sizes[1] and tonumber(spec.sizes[1][j]))) or nil
					local hitD = 14
					local ptSwatchSpec = nil
					if (spec.symbols and spec.symbols.enabled) or dynSizeVal ~= nil then
						local userR = (spec.symbols and (spec.symbols.radii[i] or spec.symbols.radii[1])) or 4.9
						local r = gridRadius or userR
						local symAlpha = alpha
						local symStrokeW = (spec.symbols and spec.symbols.strokeWidth) or 0
						if dynSizeVal ~= nil then
							local scaleMul = userR / 4.9
							if sizeMax > sizeMin then
								local norm = max(0, (dynSizeVal - sizeMin) / (sizeMax - sizeMin))
								r = (4.0 + sqrt(norm) * 14.0) * scaleMul
							else
								r = max(userR, sqrt(max(1, abs(dynSizeVal))) * 0.85 * scaleMul)
							end
							if symAlpha == 1 then symAlpha = 0.78 end
							if symStrokeW == 0 then symStrokeW = 1.2 end
						end
						hitD = max(14, r * 2 + 4)
						local shp = (spec.symbols and (spec.symbols.shapes[i] or spec.symbols.shapes[1])) or "circle"
						local fillC = (spec.symbols and spec.symbols.noFill) and "none" or ptCol
						symBuf[#symBuf + 1] = renderSymbol(shp, px, py1, r, fillC, ptCol, symStrokeW, symAlpha, customState)
						ptSwatchSpec = { shape = shp, fill = fillC, stroke = ptCol, strokeWidth = symStrokeW, r = r }
					end

					if spec.showValues then
						local sv = spec.showValues
						local offset = tonumber(sv.offset) or -6
						local fSize = tonumber(sv.fontsize) or 10
						local fColor = sv.fontcolor or grey
						local txt = formatNumber(ySeries[i][j], sv.format, spec.yType == "integer")
						valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" fill="%s" data-halo="1">%s</text>',
							px, py1 + offset, fSize, fColor, esc(txt))
					end

					if shouldEmitTip() then
						local rawVal = ySeries[i][j]
						local xLbl = getXLabel(j, xv)
						local vStr = formatNumber(rawVal, spec.yAxisFormat, spec.yType == "integer")
						local sTitle = spec.seriesTitles[i] or ("y" .. i)
						local sizeSuffix = dynSizeVal and format(' (size: %s)', esc(formatNumber(dynSizeVal, nil, false))) or ""
						local tipHtml = (nSeries > 1 or (sTitle ~= "y1" and sTitle ~= "y"))
							and format('<b>%s</b> (%s): %s%s', esc(sTitle), esc(xLbl), esc(vStr), sizeSuffix)
							or format('<b>%s</b>: %s%s', esc(xLbl), esc(vStr), sizeSuffix)
						tipBuf[#tipBuf + 1] = buildPointHit(px, py1, totalW, totalH, tipHtml, ptCol, hitD, ptSwatchSpec)
					end
				else
					if #curSeg > 0 then segments[#segments + 1] = curSeg; curSeg = {} end
				end
			end
			if #curSeg > 0 then segments[#segments + 1] = curSeg end

			local pathCol = seriesGradUrl or baseCol
			for _, seg in ipairs(segments) do
				local topPath = buildPathD(seg, spec.interpolate, false)
				if chartType == "area" then
					local areaAlpha = (alpha < 1) and alpha or (stacked and 0.85 or 0.55)
					if stacked then
						local botPts = {}
						for k = 1, #seg do botPts[k] = { seg[k][1], seg[k][3] } end
						local botPath = buildPathD(botPts, spec.interpolate, true)
						buf[#buf + 1] = format('<path d="%s %s Z" fill="%s" fill-opacity="%.2f" stroke="none"/>', topPath, botPath, pathCol, areaAlpha)
					else
						local firstX, lastX = seg[1][1], seg[#seg][1]
						buf[#buf + 1] = format('<path d="%s L %.2f,%.2f L %.2f,%.2f Z" fill="%s" fill-opacity="%.2f" stroke="none"/>',
							topPath, lastX, baseY0, firstX, baseY0, pathCol, 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, pathCol, lw, sOp)
				end
			end
		end
	end

	if spec.vAnnotations then
		for _, va in ipairs(spec.vAnnotations) do
			local xv = isDateX and parseDateValue(va.x) or tonumber(va.x)
			local px
			if isOrdinalX then
				for j = 1, nPoints do
					if tostring(xArr[j]) == tostring(va.x) then px = mapX(j, j); break end
				end
			elseif xv then
				px = mapX(xv, 1)
			end
			if px then
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1.2" stroke-dasharray="4,3" opacity="0.8"/>',
					px, padT, px, padT + plotH, grey)
				if va.label and va.label ~= "" then
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="start" font-size="10px" fill="%s" transform="rotate(-90, %.2f, %.2f)" data-halo="1">%s</text>',
						px + 4, padT + plotH - 4, grey, px + 4, padT + plotH - 4, esc(va.label))
				end
			end
		end
	end

	if spec.hAnnotations then
		for _, ha in ipairs(spec.hAnnotations) do
			local yv = tonumber(ha.y)
			if yv then
				local py = mapY(yv)
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="4,3" opacity="0.8"/>',
					padL, py, padL + plotW, py, grey)
				if ha.label and ha.label ~= "" then
					buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" font-size="10px" fill="%s" data-halo="1">%s</text>',
						padL + 4, py - 4, grey, esc(ha.label))
				end
			end
		end
	end

	for k = 1, #symBuf do buf[#buf + 1] = symBuf[k] end
	for k = 1, #valTextBuf do buf[#buf + 1] = valTextBuf[k] end
	buf[#buf + 1] = '</g>'

	if not hideAxes then
		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

	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.xTitle 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 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

	if spec.showLegend and nSeries > 0 then
		local legX = padL + plotW + 14
		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-leg-cmap-" .. i
				customState.defs[#customState.defs + 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
				if 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, baseCol)
					if spec.symbols and spec.symbols.enabled then
						local shp = spec.symbols.shapes[i] or spec.symbols.shapes[1] or "circle"
						local legR = min(8.5, max(4.5, spec.symbols.radii[i] or spec.symbols.radii[1] or 4.5))
						customState.isLegend = true
						buf[#buf + 1] = renderSymbol(shp, legX + 7, iy + 5.5, legR, spec.symbols.noFill and "none" or baseCol, baseCol, spec.symbols.strokeWidth, 1, customState)
						customState.isLegend = false
					end
				else
					buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s"/>', legX, iy, baseCol)
				end
				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

	buf[1] = format('<defs><clipPath id="gr-clip"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath>%s</defs>',
		padL, padT, plotW, plotH, table.concat(customState.defs))

	local animCls = nil
	if spec.animate ~= false and not spec.isScatterGrid then
		animCls = (chartType == "rect") and "gr-anim gr-anim-grow" or "gr-anim gr-anim-fade"
	end
	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

return p