跳转到内容

模組:沙盒/a2569875/PythonScript/ExtraLibrarys/Matplotlib/SVGRenderer

维基百科,自由的百科全书
-- Module:PythonScript/ExtraLibrarys/Matplotlib/SVGRenderer
-- 負責 Matplotlib 多子圖 (Subplots)、2D 直角座標、極座標與雷達圖 (Radar Chart)、
-- 圓餅圖 (Pie)、散佈圖 (Scatter)、階梯圖 (Step)、面積與堆疊圖 (Fill / Stackplot)、
-- 直方圖 (Hist)、箱形圖 (Boxplot)、小提琴圖 (Violinplot)、誤差條圖 (Errorbar)、
-- 甘特圖 (Gantt / broken_barh)、熱圖與色階條 (Heatmap + Colorbar)、
-- 複變函數定義域著色圖與 3D 曲面/網格/空間曲線之 SVG + HTML 混合排版渲染
local p = {}

local RendererUtils = require("Module:沙盒/a2569875/PythonScript/ExtraLibrarys/Matplotlib/RendererUtils")
local MathEngine = require("Module:沙盒/a2569875/PythonScript/ExtraLibrarys/Matplotlib/MathEngine")

local format, floor, ceil, abs, max, min, sqrt = string.format, math.floor, math.ceil, math.abs, math.max, math.min, math.sqrt
local sin, cos, exp, pi = math.sin, math.cos, math.exp, math.pi

local DEFAULT_COLORS = RendererUtils.DEFAULT_COLORS
local parseColor = RendererUtils.parseColor
local esc = RendererUtils.esc
local safeFormat = RendererUtils.safeFormat
local hasLocalizableText = RendererUtils.hasLocalizableText
local estimateHtmlTextWidth = RendererUtils.estimateHtmlTextWidth
local expandMathTags = RendererUtils.expandMathTags
local renderMathSVG = RendererUtils.renderMathSVG
local buildInternalSvgStyle = RendererUtils.buildInternalSvgStyle
local wrapHybridOutput = RendererUtils.wrapHybridOutput
local renderSymbol = RendererUtils.renderSymbol
local formatTickValue = RendererUtils.formatTickValue
local buildAxisTicks = RendererUtils.buildAxisTicks
local getComplexTicks = RendererUtils.getComplexTicks
local buildSegmentPath = RendererUtils.buildSegmentPath
local detectArrayJumps = RendererUtils.detectArrayJumps
local getDashArray = RendererUtils.getDashArray
local sampleColormap = RendererUtils.sampleColormap

-- 補充常用深色具名顏色
RendererUtils.NAMED_COLORS.darkblue = "#00008b"
RendererUtils.NAMED_COLORS.darkgreen = "#006400"
RendererUtils.NAMED_COLORS.darkmagenta = "#8b008b"
RendererUtils.NAMED_COLORS.darkcyan = "#008b8b"
RendererUtils.NAMED_COLORS.darkslateblue = "#483d8b"

-- ==========================================
-- 1. 統計計算輔助器 (供 Boxplot 與 Violinplot KDE 使用)
-- ==========================================
local function computeQuantile(sorted, q)
	local n = #sorted
	if n == 0 then return 0 end
	if n == 1 then return sorted[1] end
	local pos = 1 + (n - 1) * q
	local lo = floor(pos)
	local hi = ceil(pos)
	local frac = pos - lo
	return sorted[lo] + (sorted[hi] - sorted[lo]) * frac
end

local function computeBoxStats(rawArr, whis)
	whis = tonumber(whis) or 1.5
	local clean = {}
	local sum = 0
	for i = 1, #(rawArr or {}) do
		local v = tonumber(rawArr[i])
		if v and v == v and abs(v) < 1e12 then
			clean[#clean + 1] = v
			sum = sum + v
		end
	end
	table.sort(clean)
	local n = #clean
	if n == 0 then
		return { q1 = 0, med = 0, q3 = 0, wLow = 0, wHigh = 0, mean = 0, min = 0, max = 0, fliers = {}, sorted = {} }
	end
	local q1 = computeQuantile(clean, 0.25)
	local med = computeQuantile(clean, 0.50)
	local q3 = computeQuantile(clean, 0.75)
	local iqr = q3 - q1
	local fenceLow = q1 - whis * iqr
	local fenceHigh = q3 + whis * iqr
	local wLow, wHigh = q1, q3
	local fliers = {}
	for i = 1, n do
		local v = clean[i]
		if v >= fenceLow and v <= fenceHigh then
			if v < wLow then wLow = v end
			if v > wHigh then wHigh = v end
		else
			fliers[#fliers + 1] = v
		end
	end
	return {
		q1 = q1, med = med, q3 = q3,
		wLow = wLow, wHigh = wHigh,
		mean = sum / n,
		min = clean[1], max = clean[n],
		fliers = fliers,
		sorted = clean
	}
end

local function computeViolinKDE(sorted, nSteps)
	local n = #sorted
	if n == 0 then return {}, 0, 1 end
	local minV, maxV = sorted[1], sorted[n]
	if minV == maxV then minV, maxV = minV - 1, maxV + 1 end
	local mean = 0
	for i = 1, n do mean = mean + sorted[i] end
	mean = mean / n
	local var = 0
	for i = 1, n do var = var + (sorted[i] - mean) ^ 2 end
	local std = sqrt(var / max(1, n - 1))
	if std <= 1e-9 then std = (maxV - minV) * 0.2 end
	-- Silverman 經驗頻寬
	local bw = 1.06 * std * (n ^ (-0.2))
	if bw <= 1e-9 then bw = (maxV - minV) * 0.15 end

	nSteps = nSteps or 24
	local grid = {}
	local maxDens = 0
	local invBw = 1.0 / bw
	for k = 0, nSteps do
		local y = minV + (k / nSteps) * (maxV - minV)
		local dens = 0
		for i = 1, n do
			local u = (y - sorted[i]) * invBw
			dens = dens + exp(-0.5 * u * u)
		end
		if dens > maxDens then maxDens = dens end
		grid[k + 1] = { val = y, dens = dens }
	end
	if maxDens > 0 then
		for k = 1, #grid do grid[k].dens = grid[k].dens / maxDens end
	end
	return grid, minV, maxV
end

-- ==========================================
-- 2. 圖例 (Legend) 預排版與混合輸出輔助器
-- ==========================================
local function prepareAxesLegend(ax, palette, useHtmlLabels)
	local items = {}
	local maxItemW = 48
	local totalItemsH = 0
	local legFontSize = tonumber(ax.legend_fontsize) or 11.0
	local useMathLegend = (ax.math_legend == true)

	local function addLegendEntry(lbl, baseCol, alpha, lw, linestyle, hasMarker, marker, isPatch)
		if not lbl or lbl == "" or string.match(tostring(lbl), "^_nolegend_") then return end
		local strLbl = tostring(lbl)
		local b
		if useHtmlLabels then
			b = {
				svg = "",
				html = expandMathTags(strLbl),
				width = estimateHtmlTextWidth(strLbl, legFontSize),
				ascent = legFontSize * 0.78,
				descent = legFontSize * 0.24
			}
		elseif useMathLegend then
			b = renderMathSVG(strLbl, { fontSize = legFontSize })
		else
			b = {
				svg = format('<text x="0" y="0" font-family="sans-serif" font-size="%.1fpx" fill="#2b2b2b">%s</text>', legFontSize, esc(strLbl)),
				width = estimateHtmlTextWidth(strLbl, legFontSize),
				ascent = legFontSize * 0.75,
				descent = 3
			}
		end

		if b.width > maxItemW then maxItemW = b.width end
		local rowH = max(17, b.ascent + b.descent + 4)
		totalItemsH = totalItemsH + rowH

		items[#items + 1] = {
			box = b,
			rowH = rowH,
			color = baseCol,
			alpha = alpha,
			lw = lw,
			linestyle = linestyle,
			hasMarker = hasMarker,
			marker = marker or "circle",
			isPatch = isPatch
		}
	end

	for i, s in ipairs(ax.series or {}) do
		if s.kind == "pie" and s.labels then
			local pieColors = s.colors or palette
			for j, plbl in ipairs(s.labels) do
				local c = pieColors[(j - 1) % #pieColors + 1]
				addLegendEntry(plbl, c, tonumber(s.alpha), 0, "none", false, nil, true)
			end
		else
			local baseCol = (type(s.color) == "table" and s.color[1]) or s.color or palette[(i - 1) % #palette + 1]
			local lw = tonumber(s.linewidth) or ((s.kind == "scatter" or s.kind == "scatter3d" or s.linestyle == "none") and 0 or 2.0)
			local hasMarker = (s.marker ~= nil and s.marker ~= "" and s.marker ~= "none") or (s.kind == "scatter" or s.kind == "scatter3d" or s.kind == "errorbar")
			local isPatch = (s.kind == "bar" or s.kind == "barh" or s.kind == "broken_barh" or s.kind == "fill" or s.kind == "fill_between" or s.kind == "hist" or s.kind == "surface3d" or s.kind == "violinplot")
			addLegendEntry(s.label, baseCol, tonumber(s.alpha), lw, s.linestyle, hasMarker, s.marker, isPatch)
		end
	end

	if #items == 0 then return nil, 0, 0, legFontSize end
	local hasHdr = (ax.legend_title and ax.legend_title ~= "")
	if hasHdr then
		local hdrW = estimateHtmlTextWidth(ax.legend_title, 11.0)
		if hdrW > maxItemW then maxItemW = hdrW end
	end
	local legBoxW = max(78, maxItemW + 30)
	local legBoxH = (hasHdr and 22 or 6) + totalItemsH + 5
	return items, legBoxW, legBoxH, legFontSize
end

local function appendAxesLegend(buf, overlays, items, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
	if not items or #items == 0 then return end
	buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-lbox"/>', legX, legY, legBoxW, legBoxH)

	local curY = legY + 6
	if ax.legend_title and ax.legend_title ~= "" then
		if useHtmlLabels then
			overlays[#overlays + 1] = format(
				'<span class="mpl-ov mpl-v mpl-lhdr" style="left:%.1fpx;top:%.1fpx;">%s</span>',
				legX + 8, legY + 12, tostring(ax.legend_title)
			)
		else
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-family="sans-serif" font-size="11px" font-weight="bold" fill="#2b2b2b">%s</text>', legX + 8, legY + 15, esc(ax.legend_title))
		end
		curY = legY + 22
	end

	for _, it in ipairs(items) do
		local strokeColor, strokeOpacity = parseColor(it.color)
		local op = it.alpha or strokeOpacity
		local midY = curY + it.box.ascent - 2.2

		if it.isPatch then
			buf[#buf + 1] = format(
				'<rect x="%.1f" y="%.1f" width="14" height="8" fill="%s" stroke="%s" stroke-width="0.8"%s/>',
				legX + 8, midY - 4, strokeColor, strokeColor, op and format(' fill-opacity="%.2f"', op) or ''
			)
		else
			if it.lw > 0 then
				local dash = getDashArray(it.linestyle)
				local dashAttr = dash and format(' stroke-dasharray="%s"', dash) or ''
				buf[#buf + 1] = format(
					'<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="%.1f"%s%s/>',
					legX + 7, midY, legX + 23, midY, strokeColor, min(3.0, it.lw), dashAttr, op and format(' stroke-opacity="%.2f"', op) or ''
				)
			end
			if it.hasMarker then
				buf[#buf + 1] = renderSymbol(it.marker, legX + 15, midY, 3.5, strokeColor, strokeColor, 0.8, op, customState)
			end
		end

		if it.box.html then
			overlays[#overlays + 1] = format(
				'<span class="mpl-ov mpl-v" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>',
				legX + 27, midY, legFontSize, tostring(it.box.html)
			)
		else
			buf[#buf + 1] = format('<g transform="translate(%.2f, %.2f)">%s</g>', legX + 27, curY + it.box.ascent + 1, it.box.svg)
		end
		curY = curY + it.rowH
	end
end

-- 繪製子圖標題 (支援 size / fontsize, color, y 偏移參數)
local function appendSubplotTitle(ax, box, plotL, plotW, plotT, buf, overlays, useHtmlLabels)
	if not ax.title or ax.title == "" then return end
	local tSize = tonumber(ax.title_fontsize) or 13
	local tCol = parseColor(ax.title_color, "#2b2b2b")
	local yRel = tonumber(ax.title_y)
	local ty = plotT - 13
	if yRel and yRel > 1.0 then
		ty = max(box.y + 10, plotT - 10 - (yRel - 1.0) * 85)
	end
	local tx = plotL + plotW * 0.5
	if useHtmlLabels then
		overlays[#overlays + 1] = format(
			'<span class="mpl-ov mpl-c" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;font-weight:bold;color:%s;">%s</span>',
			tx, ty, tSize, tCol, tostring(ax.title)
		)
	else
		buf[#buf + 1] = format(
			'<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="%.1fpx" font-weight="bold" fill="%s">%s</text>',
			tx, ty + 4, tSize, tCol, esc(ax.title)
		)
	end
end

-- 產生矩形 Hover 高亮框與浮動 Tooltip (用於長條圖、甘特圖、箱形圖、小提琴圖)
local function addHoverBox(overlays, x, y, w, h, dotColor, boldText, detailText, topBound)
	local ttCls = (y - (topBound or 0) < 28) and "mpl-tt mpl-tt-b" or "mpl-tt"
	local bStr = (boldText and boldText ~= "") and format("<b>%s</b>", tostring(boldText)) or ""
	overlays[#overlays + 1] = format(
		'<div class="mpl-ht mpl-hb" style="left:%.1fpx;top:%.1fpx;width:%.1fpx;height:%.1fpx;"><span class="%s"><i class="mpl-dot" style="background:%s;"></i>%s%s</span></div>',
		x, y, max(4, w), max(4, h), ttCls, dotColor, bStr, tostring(detailText or "")
	)
end

-- 產生圓形 Hover 光圈與浮動 Tooltip (用於折線圖點、氣泡散佈圖、圓餅圖)
local function addHoverPoint(overlays, cx, cy, ringRadius, dotColor, boldText, detailText, topBound)
	local d = max(14, ringRadius * 2)
	local ttCls = (cy - (topBound or 0) < 28) and "mpl-tt mpl-tt-b" or "mpl-tt"
	local bStr = (boldText and boldText ~= "") and format("<b>%s</b>", tostring(boldText)) or ""
	overlays[#overlays + 1] = format(
		'<div class="mpl-ht mpl-hp" style="left:%.1fpx;top:%.1fpx;width:%.1fpx;height:%.1fpx;"><span class="%s"><i class="mpl-dot" style="background:%s;"></i>%s%s</span></div>',
		cx, cy, d, d, ttCls, dotColor, bStr, tostring(detailText or "")
	)
end

-- ==========================================
-- 3. 圓餅圖與甜甜圈圖渲染器 (ax.pie)
-- ==========================================
local function renderPieAxes(ax, box, buf, overlays, customState, useHtmlLabels)
	local palette = ax.palette or DEFAULT_COLORS
	local s = (ax.series and ax.series[1]) or {}
	local vals = s.x or {}
	local labels = s.labels or {}
	local explode = s.explode or {}
	local colors = s.colors or palette
	local autopct = s.autopct
	local pctdist = tonumber(s.pctdistance) or 0.6
	local lbldist = tonumber(s.labeldistance) or 1.14
	local startangle = tonumber(s.startangle) or 0
	local ccw = (s.counterclock ~= false)
	local wedgeW = tonumber(s.wedge_width) -- 若有指定則為甜甜圈圖 (Donut Chart)
	local edgeCol = parseColor(s.edgecolor, "#ffffff")
	local edgeLW = tonumber(s.linewidth) or 1.2

	local showLegend = (ax.show_legend == true)
	local legItems, legBoxW, legBoxH, legFontSize = nil, 0, 0, 11.0
	if showLegend then
		legItems, legBoxW, legBoxH, legFontSize = prepareAxesLegend(ax, palette, useHtmlLabels)
	end
	local hasTitle = (ax.title and ax.title ~= "")
	local padT = hasTitle and 30 or 14
	local padB = 14
	local padL = 24
	local padR = (showLegend and legItems) and (legBoxW + 16) or 24

	local availW = max(80, box.w - padL - padR)
	local availH = max(80, box.h - padT - padB)
	local side = min(availW, availH)
	local cx = box.x + padL + availW * 0.5
	local cy = box.y + padT + availH * 0.5
	local outerR = side * 0.38
	local innerR = (wedgeW and wedgeW > 0 and wedgeW < 1) and (outerR * (1 - wedgeW)) or 0

	local total = 0
	for i = 1, #vals do
		local v = max(0, tonumber(vals[i]) or 0)
		total = total + v
	end
	if total <= 0 then total = 1 end

	local curAng = startangle * pi / 180
	local dir = ccw and 1 or -1

	for i = 1, #vals do
		local v = max(0, tonumber(vals[i]) or 0)
		local frac = v / total
		if frac > 0 then
			local dAng = frac * 2 * pi * dir
			local nextAng = curAng + dAng
			local midAng = curAng + dAng * 0.5
			local expDist = (tonumber(explode[i]) or 0) * outerR
			local wcx = cx + expDist * cos(midAng)
			local wcy = cy - expDist * sin(midAng)
			local wCol, wAlpha = parseColor(colors[(i - 1) % #colors + 1])
			local opAttr = (s.alpha or wAlpha) and format(' fill-opacity="%.2f"', tonumber(s.alpha or wAlpha)) or ''

			if frac >= 0.9999 then
				if innerR > 0 then
					local pathD = 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",
						wcx + outerR, wcy, outerR, outerR, wcx - outerR, wcy, outerR, outerR, wcx + outerR, wcy,
						wcx + innerR, wcy, innerR, innerR, wcx - innerR, wcy, innerR, innerR, wcx + innerR, wcy
					)
					buf[#buf + 1] = format('<path d="%s" fill="%s" fill-rule="evenodd" stroke="%s" stroke-width="%.1f"%s/>', pathD, wCol, edgeCol, edgeLW, opAttr)
				else
					buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>', wcx, wcy, outerR, wCol, edgeCol, edgeLW, opAttr)
				end
			else
				local x1, y1 = wcx + outerR * cos(curAng), wcy - outerR * sin(curAng)
				local x2, y2 = wcx + outerR * cos(nextAng), wcy - outerR * sin(nextAng)
				local largeArc = (frac > 0.5) and 1 or 0
				local sweepOuter = ccw and 0 or 1
				local pathD
				if innerR > 0 then
					local ix1, iy1 = wcx + innerR * cos(nextAng), wcy - innerR * sin(nextAng)
					local ix2, iy2 = wcx + innerR * cos(curAng), wcy - innerR * sin(curAng)
					local sweepInner = ccw and 1 or 0
					pathD = format(
						"M %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
						x1, y1, outerR, outerR, largeArc, sweepOuter, x2, y2,
						ix1, iy1, innerR, innerR, largeArc, sweepInner, ix2, iy2
					)
				else
					pathD = format(
						"M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
						wcx, wcy, x1, y1, outerR, outerR, largeArc, sweepOuter, x2, y2
					)
				end
				buf[#buf + 1] = format('<path d="%s" fill="%s" stroke="%s" stroke-width="%.1f" stroke-linejoin="round"%s/>', pathD, wCol, edgeCol, edgeLW, opAttr)
			end

			-- 繪製百分比標籤 (autopct)
			if autopct and autopct ~= "" then
				local pctVal = frac * 100
				local pctStr = safeFormat(tostring(autopct), pctVal)
				local pr = (innerR > 0) and ((outerR + innerR) * 0.5) or (outerR * pctdist)
				local px = wcx + pr * cos(midAng)
				local py = wcy - pr * sin(midAng) + 3.5
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-val">%s</text>', px, py, esc(pctStr))
			end

			-- 繪製扇形外部文字標籤 (labels)
			local lbl = labels[i]
			if lbl and tostring(lbl) ~= "" then
				local lr = outerR * lbldist
				local lx = wcx + lr * cos(midAng)
				local ly = wcy - lr * sin(midAng)
				local cosM = cos(midAng)
				if useHtmlLabels then
					local alignCls = (cosM > 0.25) and "mpl-v" or ((cosM < -0.25) and "mpl-r" or "mpl-c")
					overlays[#overlays + 1] = format('<span class="mpl-ov %s mpl-fs11" style="left:%.1fpx;top:%.1fpx;">%s</span>', alignCls, lx, ly, tostring(lbl))
				else
					local anc = (cosM > 0.25) and "start" or ((cosM < -0.25) and "end" or "middle")
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-tk">%s</text>', lx, ly + 3.5, anc, esc(lbl))
				end
			end

			-- 圓餅圖扇形 Hover 隱形光圈與 Tooltip (如 "D: 300 (22.2%)")
			local hr = (innerR > 0) and ((outerR + innerR) * 0.5) or (outerR * 0.55)
			local hcx = wcx + hr * cos(midAng)
			local hcy = wcy - hr * sin(midAng)
			local ringR = max(12, min(22, outerR * 0.24))
			local sliceTitle = (lbl and tostring(lbl) ~= "") and tostring(lbl) or ("Slice " .. i)
			local sliceDetail = format(": %g (%.1f%%)", v, frac * 100)
			addHoverPoint(overlays, hcx, hcy, ringR, wCol, sliceTitle, sliceDetail, box.y)

			curAng = nextAng
		end
	end

	appendSubplotTitle(ax, box, box.x + padL, availW, box.y + padT, buf, overlays, useHtmlLabels)

	if showLegend and legItems then
		local legX = box.x + box.w - legBoxW - 8
		local legY = box.y + padT
		appendAxesLegend(buf, overlays, legItems, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
	end
end

-- ==========================================
-- 4. 2D 直角座標、極座標與雷達圖 (Polar / Radar) 渲染器
-- ==========================================
local function render2DAxes(ax, box, clipId, buf, overlays, customState, useHtmlLabels, animateMode)
	-- 若包含 pie 序列,自動轉交圓餅圖專用渲染器
	for _, s in ipairs(ax.series or {}) do
		if s.kind == "pie" then
			renderPieAxes(ax, box, buf, overlays, customState, useHtmlLabels)
			return nil
		end
	end

	local palette = ax.palette or DEFAULT_COLORS
	local series_list = ax.series or {}
	local isPolarCoord = (ax.projection == "polar")
	local hasExplicitPolarProj = isPolarCoord
	if not isPolarCoord and #series_list > 0 then
		local allP = true
		for _, s in ipairs(series_list) do
			if not s.polar then allP = false; break end
		end
		isPolarCoord = allP
	end

	-- 若透過 subplot_kw=dict(polar=True) 建立,預設採用正圓形極座標網格 ("circle") 以完美支援雷達圖
	local defaultPolarShape = hasExplicitPolarProj and "circle" or "rect"
	local polarShape = string.lower(tostring(ax.polar_shape or defaultPolarShape))
	local isPolarCircle = isPolarCoord and (polarShape == "circle" or polarShape == "round")
	local isPolarRect = isPolarCoord and (not isPolarCircle)

	local equalScale = isPolarCoord or (ax.equal_scale == true) or (ax.aspect == "equal")
	local strictTicks = isPolarRect and true or ax.strict_ticks
	local xTickStep, yTickStep = tonumber(ax.x_tick_step), tonumber(ax.y_tick_step)
	local xUnit = tonumber(ax.x_unit) or 1.0
	local xUnitLabel = mw.text.trim(tostring(ax.x_unit_label or ""))
	local yUnit = tonumber(ax.y_unit) or 1.0
	local yUnitLabel = mw.text.trim(tostring(ax.y_unit_label or ""))
	local hasXUnit = (xUnitLabel ~= "") or (abs(xUnit - 1.0) > 1e-9)
	local hasYUnit = (yUnitLabel ~= "") or (abs(yUnit - 1.0) > 1e-9)
	local tickFracX = (ax.tick_fraction ~= nil and ax.tick_fraction ~= "auto") and (ax.tick_fraction == true) or hasXUnit
	local tickFracY = (ax.tick_fraction ~= nil and ax.tick_fraction ~= "auto") and (ax.tick_fraction == true) or hasYUnit

	local showLegend = (ax.show_legend == true)
	local legItems, legBoxW, legBoxH, legFontSize = nil, 0, 0, 11.0
	if showLegend then
		legItems, legBoxW, legBoxH, legFontSize = prepareAxesLegend(ax, palette, useHtmlLabels)
	end
	local legLoc = string.lower(tostring(ax.legend_loc or "upper right"))
	local isOutsideLegend = showLegend and legItems and (legLoc == "right" or legLoc == "outside")

	local hasColorbar = (ax.colorbar == true)
	local hasTitle = (ax.title and ax.title ~= "")
	local xLabel = ax.xlabel
	local yLabel = ax.ylabel

	local titleExtraT = (hasTitle and tonumber(ax.title_y) and tonumber(ax.title_y) > 1.0) and 14 or 0
	local padL = yLabel and 48 or (isPolarCircle and 38 or 36)
	local padR = (isOutsideLegend and (legBoxW + 12) or (isPolarCircle and 38 or 14)) + (hasColorbar and 48 or 0)
	local padT = (hasTitle and 30 or (isPolarCircle and 26 or 12)) + titleExtraT
	local padB = xLabel and 36 or (isPolarCircle and 26 or 22)

	local plotW = max(50, box.w - padL - padR)
	local plotH = max(50, box.h - padT - padB)
	local plotL = box.x + padL
	local plotT = box.y + padT

	if isPolarCircle then
		local side = min(plotW, plotH)
		plotL = plotL + (plotW - side) * 0.5
		plotT = plotT + (plotH - side) * 0.5
		plotW, plotH = side, side
	end

	-- 掃描實際數據值域並預處理統計圖表
	local dataXMin, dataXMax = math.huge, -math.huge
	local dataYMin, dataYMax = math.huge, -math.huge
	local maxPolarR = 0
	local lastCmap, lastVMin, lastVMax = "viridis", 0, 1

	local function updateBounds(vx, vy)
		if vx and vy and vx == vx and vy == vy and abs(vx) < 1e12 and abs(vy) < 1e12 then
			if vx < dataXMin then dataXMin = vx end
			if vx > dataXMax then dataXMax = vx end
			if vy < dataYMin then dataYMin = vy end
			if vy > dataYMax then dataYMax = vy end
			local r_d = sqrt(vx * vx + vy * vy)
			if r_d > maxPolarR then maxPolarR = r_d end
		end
	end

	for _, s in ipairs(series_list) do
		local isPol = isPolarCoord or (s.polar == true)
		if s.kind == "line" or s.kind == "scatter" or s.kind == "fill" or s.kind == "errorbar" then
			local xs, ys = s.x or {}, s.y or {}
			local n = min(#xs, #ys)
			s._cx, s._cy = {}, {}
			for j = 1, n do
				local a, b = tonumber(xs[j]), tonumber(ys[j])
				if a and b and a == a and b == b then
					local vx, vy = a, b
					if isPol then
						local th, r = a, b
						if s.r_first then r, th = a, b end
						vx, vy = r * cos(th), r * sin(th)
					end
					s._cx[j], s._cy[j] = vx, vy
					updateBounds(vx, vy)
					if s.kind == "errorbar" then
						local yL = s.yerr_low and tonumber(s.yerr_low[j]) or 0
						local yH = s.yerr_high and tonumber(s.yerr_high[j]) or 0
						local xL = s.xerr_low and tonumber(s.xerr_low[j]) or 0
						local xH = s.xerr_high and tonumber(s.xerr_high[j]) or 0
						updateBounds(vx - xL, vy - yL)
						updateBounds(vx + xH, vy + yH)
					end
				else
					s._cx[j], s._cy[j] = false, false
				end
			end
			for _, mk in ipairs(s.marks or {}) do
				updateBounds(mk.x, mk.y)
			end

		elseif s.kind == "bar" or s.kind == "hist" then
			local xs, hs = s.x or {}, s.height or {}
			local wIsTbl = (type(s.width) == "table")
			local bIsTbl = (type(s.bottom) == "table")
			for j = 1, min(#xs, #hs) do
				local vx, vh = tonumber(xs[j]), tonumber(hs[j])
				local w = wIsTbl and (tonumber(s.width[j]) or 0.8) or (tonumber(s.width) or 0.8)
				local bot = bIsTbl and (tonumber(s.bottom[j]) or 0) or (tonumber(s.bottom) or 0)
				if vx and vh then
					if isPol then
						if bot + vh > maxPolarR then maxPolarR = bot + vh end
					elseif s.align == "edge" then
						updateBounds(vx, bot)
						updateBounds(vx + w, bot + vh)
					else
						updateBounds(vx - w * 0.55, bot)
						updateBounds(vx + w * 0.55, bot + vh)
					end
				end
			end

		elseif s.kind == "barh" then
			local ys, ws = s.x or {}, s.height or {}
			local hIsTbl = (type(s.width) == "table")
			local lIsTbl = (type(s.bottom) == "table")
			for j = 1, min(#ys, #ws) do
				local vy, vw = tonumber(ys[j]), tonumber(ws[j])
				local h = hIsTbl and (tonumber(s.width[j]) or 0.8) or (tonumber(s.width) or 0.8)
				local left = lIsTbl and (tonumber(s.bottom[j]) or 0) or (tonumber(s.bottom) or 0)
				if vy and vw then
					if s.align == "edge" then
						updateBounds(left, vy)
						updateBounds(left + vw, vy + h)
					else
						updateBounds(left, vy - h * 0.55)
						updateBounds(left + vw, vy + h * 0.55)
					end
				end
			end

		elseif s.kind == "broken_barh" then
			local xranges = s.xranges or {}
			local y0 = tonumber(s.yrange and s.yrange[1]) or 0
			local yh = tonumber(s.yrange and s.yrange[2]) or 1
			for _, xr in ipairs(xranges) do
				local xstart = tonumber(xr[1])
				local xwidth = tonumber(xr[2])
				if xstart and xwidth then
					updateBounds(xstart, y0)
					updateBounds(xstart + xwidth, y0 + yh)
				end
			end

		elseif s.kind == "boxplot" or s.kind == "violinplot" then
			local datasets = s.datasets or {}
			local positions = s.positions or {}
			local vert = (s.vert ~= false)
			local boxW = tonumber(s.widths) or 0.5
			s._stats = {}
			for k, ds in ipairs(datasets) do
				local pos = tonumber(positions[k]) or k
				local st = computeBoxStats(ds, s.whis)
				if s.kind == "violinplot" then
					st.kde = computeViolinKDE(st.sorted, 24)
				end
				s._stats[k] = { pos = pos, st = st }
				if vert then
					updateBounds(pos - boxW * 0.8, st.min)
					updateBounds(pos + boxW * 0.8, st.max)
				else
					updateBounds(st.min, pos - boxW * 0.8)
					updateBounds(st.max, pos + boxW * 0.8)
				end
			end

		elseif s.kind == "fill_between" then
			local xs, y1s, y2s = s.x or {}, s.y1 or {}, s.y2 or {}
			for j = 1, #xs do
				local vx = tonumber(xs[j])
				local v1 = tonumber(y1s[j])
				local v2 = type(y2s) == "table" and tonumber(y2s[j]) or tonumber(y2s) or 0
				if vx and v1 then updateBounds(vx, v1) end
				if vx and v2 then updateBounds(vx, v2) end
			end

		elseif s.kind == "imshow" then
			local ext = s.extent
			local g = s.grid or {}
			local nR = #g
			local nC = (nR > 0 and #g[1]) or 1
			local x0 = (ext and tonumber(ext[1])) or -0.5
			local x1 = (ext and tonumber(ext[2])) or (nC - 0.5)
			local y0 = (ext and tonumber(ext[3])) or -0.5
			local y1 = (ext and tonumber(ext[4])) or (nR - 0.5)
			updateBounds(x0, y0)
			updateBounds(x1, y1)
		end
	end

	local xMin = ax.xlim and tonumber(ax.xlim[1]) or nil
	local xMax = ax.xlim and tonumber(ax.xlim[2]) or nil
	local yMin = ax.ylim and tonumber(ax.ylim[1]) or nil
	local yMax = ax.ylim and tonumber(ax.ylim[2]) or nil

	if isPolarCoord then
		-- 在極座標系中,ax.set_ylim([rMin, rMax]) 代表設定極徑上限,而非直角座標 Y 範圍
		local userRMax = (ax.ylim and tonumber(ax.ylim[2])) or nil
		if userRMax and userRMax > 0 then maxPolarR = userRMax end
		if maxPolarR <= 0 then maxPolarR = 1 end
		local baseRTicks = buildAxisTicks(0, maxPolarR, yTickStep or xTickStep, xUnit, hasXUnit, true, 4)
		local topR = userRMax or ((#baseRTicks > 0 and baseRTicks[#baseRTicks] >= maxPolarR * 0.95) and baseRTicks[#baseRTicks] or maxPolarR)
		local rBound = topR * (isPolarRect and 1.15 or 1.04)
		xMin, xMax = -rBound, rBound
		yMin, yMax = -rBound, rBound
	else
		if dataXMin == math.huge then dataXMin, dataXMax = 0, 1 end
		if dataYMin == math.huge then dataYMin, dataYMax = 0, 1 end
		local dxSpan = (dataXMax > dataXMin) and (dataXMax - dataXMin) or 1
		local dySpan = (dataYMax > dataYMin) and (dataYMax - dataYMin) or 1
		xMin = xMin or (dataXMin - dxSpan * 0.05)
		xMax = xMax or (dataXMax + dxSpan * 0.05)
		yMin = yMin or (dataYMin - dySpan * 0.06)
		yMax = yMax or (dataYMax + dySpan * 0.06)
		if xMin == xMax then xMin, xMax = xMin - 1, xMax + 1 end
		if yMin == yMax then yMin, yMax = yMin - 1, yMax + 1 end
	end

	if equalScale then
		local xSpan0, ySpan0 = xMax - xMin, yMax - yMin
		local scaleX, scaleY = plotW / xSpan0, plotH / ySpan0
		if scaleX < scaleY then
			local targetYSpan = plotH / scaleX
			local yMid = (yMin + yMax) * 0.5
			yMin, yMax = yMid - targetYSpan * 0.5, yMid + targetYSpan * 0.5
		elseif scaleY < scaleX then
			local targetXSpan = plotW / scaleY
			local xMid = (xMin + xMax) * 0.5
			xMin, xMax = xMid - targetXSpan * 0.5, xMid + targetXSpan * 0.5
		end
	end

	local xSpan, ySpan = xMax - xMin, yMax - yMin
	local function mapX(x) return plotL + ((x - xMin) / xSpan) * plotW end
	local function mapY(y) return plotT + plotH - ((y - yMin) / ySpan) * plotH end

	local showGrid = (ax.grid == nil) and isPolarCoord or (ax.grid == true)
	local showXGrid = (ax.x_grid ~= nil) and (ax.x_grid == true) or showGrid
	local showYGrid = (ax.y_grid ~= nil) and (ax.y_grid == true) or showGrid
	local xFmt, yFmt = ax.x_format, ax.y_format
	local clipTag

	-- 1. 繪製底板、網格與座標刻度 (含雷達圖自訂角度 xticks、NullFormatter 隱藏標籤與中文 xticklabels)
	local hideXLabels = (ax.hide_xtick_labels == true)
	local hideYLabels = (ax.hide_ytick_labels == true)
	local polarOuterCircleSvg = nil

	if isPolarCircle then
		local cx, cy = mapX(0), mapY(0)
		local userRMax = (ax.ylim and tonumber(ax.ylim[2])) or nil
		local maxRMath = userRMax or (maxPolarR > 0 and maxPolarR or (min(xSpan, ySpan) * 0.45))
		local rTicks = ax.yticks or buildAxisTicks(0, maxRMath, yTickStep or xTickStep, xUnit, hasXUnit, true, 4)
		local outerRMath = userRMax or ((#rTicks > 0 and rTicks[#rTicks] >= maxRMath * 0.95) and rTicks[#rTicks] or maxPolarR)
		local outerRPx = (outerRMath / xSpan) * plotW

		clipTag = format('<clipPath id="%s"><circle cx="%.2f" cy="%.2f" r="%.2f"/></clipPath>', clipId, cx, cy, outerRPx + 2.5)
		buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="#ffffff" stroke="none"/>', cx, cy, outerRPx)
		polarOuterCircleSvg = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#222222" stroke-width="1.2"/>', cx, cy, outerRPx)

		if showGrid then
			for _, rk in ipairs(rTicks) do
				if rk > 1e-9 and rk < outerRMath - 1e-6 then
					local rPx = (rk / xSpan) * plotW
					buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#b8b8b8" stroke-width="0.9"/>', cx, cy, rPx)
				end
			end
		end

		if ax.xticks and #ax.xticks > 0 then
			local xLabels = ax.xticklabels or {}
			local xtickFs = tonumber(ax.xtick_fontsize) or 11
			for idx, radVal in ipairs(ax.xticks) do
				local rad = tonumber(radVal) or 0
				local cosA, sinA = cos(rad), sin(rad)
				local ex, ey = cx + outerRPx * cosA, cy - outerRPx * sinA
				if showGrid then
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b8b8b8" stroke-width="0.9"/>', cx, cy, ex, ey)
				end
				if not hideXLabels then
					local lbl = xLabels[idx]
					if lbl == nil then
						local deg = floor((rad * 180 / pi) % 360 + 0.5)
						lbl = tostring(deg) .. "°"
					else
						lbl = tostring(lbl)
					end
					if lbl ~= "" then
						local lblR = outerRPx + 15
						local lx, ly = cx + lblR * cosA, cy - lblR * sinA
						if useHtmlLabels and hasLocalizableText(lbl) then
							local alignCls = "mpl-c"
							if cosA > 0.35 then alignCls = "mpl-v"; lx = lx - 4
							elseif cosA < -0.35 then alignCls = "mpl-r"; lx = lx + 4 end
							overlays[#overlays + 1] = format(
								'<span class="mpl-ov %s" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>',
								alignCls, lx, ly, xtickFs, lbl
							)
						else
							local anchor = "middle"
							if cosA > 0.25 then anchor = "start"; lx = lx - 2
							elseif cosA < -0.25 then anchor = "end"; lx = lx + 2 end
							buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-ptk">%s</text>', lx, ly + 3.5, anchor, esc(lbl))
						end
					end
				end
			end
		else
			-- 當使用 NullFormatter 隱藏角度標籤或畫布較小時,採用 45° (8 等分) 放射網格,與 Matplotlib 標準一致
			local angleStep = (hideXLabels or outerRPx < 75) and 45 or 30
			local angleLabels = (angleStep == 45) and {
				[0] = "0", [45] = "π/4", [90] = "π/2", [135] = "3π/4",
				[180] = "π", [225] = "5π/4", [270] = "3π/2", [315] = "7π/4"
			} or {
				[0] = "0", [30] = "π/6", [60] = "π/3", [90] = "π/2",
				[120] = "2π/3", [150] = "5π/6", [180] = "π", [210] = "7π/6",
				[240] = "4π/3", [270] = "3π/2", [300] = "5π/3", [330] = "11π/6"
			}
			for deg = 0, 359, angleStep do
				local rad = deg * pi / 180
				local cosA, sinA = cos(rad), sin(rad)
				local ex, ey = cx + outerRPx * cosA, cy - outerRPx * sinA
				if showGrid then
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b8b8b8" stroke-width="0.9"/>', cx, cy, ex, ey)
				end
				if not hideXLabels then
					local lblR = outerRPx + 13
					local lx, ly = cx + lblR * cosA, cy - lblR * sinA + 3.5
					local anchor = "middle"
					if cosA > 0.25 then anchor = "start"; lx = lx - 2
					elseif cosA < -0.25 then anchor = "end"; lx = lx + 2 end
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-ptk">%s</text>', lx, ly, anchor, angleLabels[deg] or (deg .. "°"))
				end
			end
		end

		if not hideYLabels then
			for _, rk in ipairs(rTicks) do
				if rk > 1e-9 and rk <= outerRMath + 1e-6 then
					local rPx = (rk / xSpan) * plotW
					local rLbl = formatTickValue(rk, xUnit, xUnitLabel, tickFracX, xFmt)
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-rtk">%s</text>', cx + rPx * cos(0.38), cy - rPx * sin(0.38) + 3, esc(rLbl))
				end
			end
		end
	else
		clipTag = format('<clipPath id="%s"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath>', clipId, plotL, plotT, plotW, plotH)
		buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bg"/>', plotL, plotT, plotW, plotH)

		local xTicks = ax.xticks or buildAxisTicks(xMin, xMax, xTickStep, xUnit, hasXUnit, strictTicks, 6)
		local yTicks = ax.yticks or buildAxisTicks(yMin, yMax, yTickStep, yUnit, hasYUnit, strictTicks, 6)
		local xLabels = ax.xticklabels
		local yLabels = ax.yticklabels
		local xtickFs = tonumber(ax.xtick_fontsize) or 10
		local ytickFs = tonumber(ax.ytick_fontsize) or 10

		for idx, xVal in ipairs(xTicks) do
			local px = mapX(xVal)
			if px >= plotL - 0.5 and px <= plotL + plotW + 0.5 then
				if (not isPolarRect) and showXGrid and px > plotL + 1 and px < plotL + plotW - 1 then
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="mpl-grd"/>', px, plotT, px, plotT + plotH)
				end
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#333" stroke-width="1"/>', px, plotT + plotH, px, plotT + plotH + 3.5)
				if not hideXLabels then
					local lbl = (xLabels and xLabels[idx] ~= nil) and tostring(xLabels[idx]) or formatTickValue(xVal, xUnit, xUnitLabel, tickFracX, xFmt)
					if useHtmlLabels and hasLocalizableText(lbl) then
						overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>', px, plotT + plotH + 11, xtickFs, lbl)
					else
						buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', px, plotT + plotH + 14.5, esc(lbl))
					end
				end
			end
		end

		for idx, yVal in ipairs(yTicks) do
			local py = mapY(yVal)
			if py >= plotT - 0.5 and py <= plotT + plotH + 0.5 then
				if (not isPolarRect) and showYGrid and py > plotT + 1 and py < plotT + plotH - 1 then
					buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="mpl-grd"/>', plotL, py, plotL + plotW, py)
				end
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#333" stroke-width="1"/>', plotL - 3.5, py, plotL, py)
				if not hideYLabels then
					local lbl = (yLabels and yLabels[idx] ~= nil) and tostring(yLabels[idx]) or formatTickValue(yVal, yUnit, yUnitLabel, tickFracY, yFmt)
					if useHtmlLabels and hasLocalizableText(lbl) then
						overlays[#overlays + 1] = format('<span class="mpl-ov mpl-r" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>', plotL - 5, py, ytickFs, lbl)
					else
						buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="mpl-tk">%s</text>', plotL - 5.5, py + 3.5, esc(lbl))
					end
				end
			end
		end

		if isPolarRect then
			local cx, cy = mapX(0), mapY(0)
			local fitRMath = min(abs(xMin), abs(xMax), abs(yMin), abs(yMax))
			local baseRTicks = buildAxisTicks(0, fitRMath, xTickStep or yTickStep, xUnit, hasXUnit, true, 4)
			local rStep = (#baseRTicks >= 2) and (baseRTicks[2] - baseRTicks[1]) or (fitRMath / 4)
			if rStep <= 0 then rStep = 1 end

			local refRMath = baseRTicks[#baseRTicks] or fitRMath
			if refRMath > fitRMath * 0.96 and #baseRTicks >= 2 then refRMath = baseRTicks[#baseRTicks - 1] end
			local refRPx = (refRMath / xSpan) * plotW
			local maxCornerR = sqrt(max(xMin * xMin, xMax * xMax) + max(yMin * yMin, yMax * yMax))
			local maxCornerPx = (maxCornerR / xSpan) * plotW

			buf[#buf + 1] = format('<g clip-path="url(#%s)">', clipId)
			if showGrid then
				local rk, safety = rStep, 0
				while rk <= maxCornerR + rStep * 0.5 and safety < 60 do
					safety = safety + 1
					local rPx = (rk / xSpan) * plotW
					local isRefCircle = (abs(rk - refRMath) < rStep * 0.1)
					buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" class="%s"/>', cx, cy, rPx, isRefCircle and "mpl-pref" or "mpl-pgrd")
					rk = rk + rStep
				end
			end
			local angleStep = (refRPx < 75) and 45 or 30
			local angleLabels = (angleStep == 45) and {
				[0] = "0", [45] = "π/4", [90] = "π/2", [135] = "3π/4",
				[180] = "π", [225] = "5π/4", [270] = "3π/2", [315] = "7π/4"
			} or {
				[0] = "0", [30] = "π/6", [60] = "π/3", [90] = "π/2",
				[120] = "2π/3", [150] = "5π/6", [180] = "π", [210] = "7π/6",
				[240] = "4π/3", [270] = "3π/2", [300] = "5π/3", [330] = "11π/6"
			}
			for deg = 0, 359, angleStep do
				local rad = deg * pi / 180
				local cosA, sinA = cos(rad), sin(rad)
				local ex, ey = cx + maxCornerPx * cosA, cy - maxCornerPx * sinA
				if showGrid and (deg % 90 ~= 0) then
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-pgrd"/>', cx, cy, ex, ey)
				end
			end
			if yMin <= 0 and yMax >= 0 then buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="mpl-ax"/>', plotL, cy, plotL + plotW, cy) end
			if xMin <= 0 and xMax >= 0 then buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="mpl-ax"/>', cx, plotT, cx, plotT + plotH) end
			for deg = 0, 359, angleStep do
				local rad = deg * pi / 180
				local cosA, sinA = cos(rad), sin(rad)
				local lblR = refRPx + 11
				local lx = max(plotL + 14, min(plotL + plotW - 14, cx + lblR * cosA))
				local ly = max(plotT + 11, min(plotT + plotH - 4, cy - lblR * sinA + 3.5))
				local anchor = "middle"
				if cosA > 0.35 then anchor = "start"; lx = lx - 2
				elseif cosA < -0.35 then anchor = "end"; lx = lx + 2 end
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-ptk">%s</text>', lx, ly, anchor, angleLabels[deg] or (deg .. "°"))
			end
			local rk = rStep
			while rk <= refRMath + 1e-6 do
				local rPx = (rk / xSpan) * plotW
				if cx + rPx < plotL + plotW - 18 then
					local rLbl = formatTickValue(rk, xUnit, xUnitLabel, tickFracX, xFmt)
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-rtk">%s</text>', cx + rPx, cy + 11, esc(rLbl))
				end
				rk = rk + rStep
			end
			buf[#buf + 1] = '</g>'
		elseif ax.origin_axes then
			if yMin <= 0 and yMax >= 0 then
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="mpl-ax"/>', plotL, mapY(0), plotL + plotW, mapY(0))
			end
			if xMin <= 0 and xMax >= 0 then
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="mpl-ax"/>', mapX(0), plotT, mapX(0), plotT + plotH)
			end
		end
	end

	-- 2. 繪製所有資料序列 (包覆於 clipPath 內)
	buf[#buf + 1] = format('<g clip-path="url(#%s)">', clipId)
	local yMargin = ySpan * 3.5
	local lineClass = (animateMode == "hover" or animateMode == "draw" or animateMode == "auto") and "mpl-line mpl-anim-path" or "mpl-line"

	for i, s in ipairs(series_list) do
		local rawColor = (type(s.color) == "table" and s.color[1]) or s.color or palette[(i - 1) % #palette + 1]
		local strokeColor, strokeOpacity = parseColor(rawColor)
		local alpha = tonumber(s.alpha) or strokeOpacity

		if s.kind == "imshow" then
			local grid = s.grid or {}
			local g = s.grid or {}
			local nR = #grid
			local nC = (nR > 0 and #g and #grid[1]) or (nR > 0 and #grid[1]) or 1
			local ext = s.extent
			local gx0 = (ext and tonumber(ext[1])) or -0.5
			local gx1 = (ext and tonumber(ext[2])) or (nC - 0.5)
			local gy0 = (ext and tonumber(ext[3])) or -0.5
			local gy1 = (ext and tonumber(ext[4])) or (nR - 0.5)
			local pxL, pxR = mapX(gx0), mapX(gx1)
			local pyT, pyB = mapY(gy1), mapY(gy0)
			local cellW = (pxR - pxL) / max(1, nC)
			local cellH = (pyB - pyT) / max(1, nR)

			local vMin, vMax = tonumber(s.vmin) or math.huge, tonumber(s.vmax) or -math.huge
			if vMin == math.huge or vMax == -math.huge then
				for r = 1, nR do
					for c = 1, nC do
						local v = tonumber(grid[r][c])
						if v and v == v then
							if v < vMin then vMin = v end
							if v > vMax then vMax = v end
						end
					end
				end
			end
			if vMin == math.huge then vMin, vMax = 0, 1 end
			local vSpan = (vMax > vMin) and (vMax - vMin) or 1
			local cmap = s.cmap or ax.colormap or "viridis"
			lastCmap, lastVMin, lastVMax = cmap, vMin, vMax
			local isLower = (s.origin == "lower")

			buf[#buf + 1] = '<g shape-rendering="crispEdges">'
			for r = 1, nR do
				local rowIdx = isLower and (nR - r + 1) or r
				local row = grid[rowIdx] or {}
				local py = pyT + (r - 1) * cellH
				local c = 1
				while c <= nC do
					local v = tonumber(row[c]) or vMin
					local colHex = sampleColormap(cmap, (v - vMin) / vSpan, 1.0)
					local runLen = 1
					while (c + runLen <= nC) do
						local vn = tonumber(row[c + runLen]) or vMin
						if sampleColormap(cmap, (vn - vMin) / vSpan, 1.0) ~= colHex then break end
						runLen = runLen + 1
					end
					buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>', pxL + (c - 1) * cellW, py, runLen * cellW, cellH, colHex)
					c = c + runLen
				end
			end
			buf[#buf + 1] = '</g>'

		elseif s.kind == "fill" then
			local cxArr, cyArr = s._cx or {}, s._cy or {}
			local n = min(#cxArr, #cyArr)
			if n >= 2 then
				local pbuf = {}
				for j = 1, n do
					local vx, vy = cxArr[j], cyArr[j]
					if vx and vy then
						pbuf[#pbuf + 1] = format("%s %.2f,%.2f", (#pbuf == 0 and "M" or "L"), mapX(vx), mapY(vy))
					end
				end
				pbuf[#pbuf + 1] = "Z"
				local fAlpha = alpha or 0.30
				local eAttr = (s.edgecolor and s.edgecolor ~= "none") and format(' stroke="%s" stroke-width="%.1f"', parseColor(s.edgecolor), tonumber(s.linewidth) or 1.0) or ' stroke="none"'
				buf[#buf + 1] = format('<path d="%s" fill="%s" fill-opacity="%.2f"%s/>', table.concat(pbuf, " "), strokeColor, fAlpha, eAttr)
			end

		elseif s.kind == "fill_between" then
			local xs, y1s, y2s = s.x or {}, s.y1 or {}, s.y2 or {}
			local whereArr = s.where
			local n = min(#xs, #y1s)
			if n >= 2 then
				local pbuf = {}
				for j = 1, n do
					if not whereArr or whereArr[j] ~= false then
						local vx, v1 = tonumber(xs[j]), tonumber(y1s[j])
						if vx and v1 then
							pbuf[#pbuf + 1] = format("%s %.2f,%.2f", (#pbuf == 0 and "M" or "L"), mapX(vx), mapY(v1))
						end
					end
				end
				for j = n, 1, -1 do
					if not whereArr or whereArr[j] ~= false then
						local vx = tonumber(xs[j])
						local v2 = type(y2s) == "table" and tonumber(y2s[j]) or tonumber(y2s) or 0
						if vx and v2 then
							pbuf[#pbuf + 1] = format("L %.2f,%.2f", mapX(vx), mapY(v2))
						end
					end
				end
				if #pbuf > 0 then
					pbuf[#pbuf + 1] = "Z"
					local fAlpha = alpha or 0.35
					buf[#buf + 1] = format('<path d="%s" fill="%s" fill-opacity="%.2f" stroke="none"/>', table.concat(pbuf, " "), strokeColor, fAlpha)
				end
			end

		elseif s.kind == "bar" or s.kind == "barh" or s.kind == "hist" then
			local xs, hs = s.x or {}, s.height or {}
			local wIsTbl = (type(s.width) == "table")
			local bIsTbl = (type(s.bottom) == "table")
			local cIsTbl = (type(s.color) == "table")
			local barCols = s.bar_colors or {}
			local barAlphas = s.bar_alphas or {}
			local barEdges = s.bar_edgecolors or {}
			local barLWs = s.bar_linewidths or {}
			local defaultECol = s.edgecolor and parseColor(s.edgecolor) or strokeColor
			local defaultLW = tonumber(s.linewidth) or 0.8
			local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or nil
			local isPolBar = (isPolarCoord or s.polar) and (s.kind == "bar" or s.kind == "hist")

			for j = 1, min(#xs, #hs) do
				local v1, v2 = tonumber(xs[j]), tonumber(hs[j])
				local w = wIsTbl and (tonumber(s.width[j]) or 0.8) or (tonumber(s.width) or 0.8)
				local bot = bIsTbl and (tonumber(s.bottom[j]) or 0) or (tonumber(s.bottom) or 0)
				local bCol = (barCols[j] and parseColor(barCols[j], strokeColor))
					or (cIsTbl and parseColor(s.color[(j - 1) % #s.color + 1], strokeColor))
					or strokeColor
				local bAlpha = tonumber(barAlphas[j]) or alpha
				local eCol = (barEdges[j] and parseColor(barEdges[j])) or defaultECol
				local lw = tonumber(barLWs[j]) or defaultLW
				local opAttr = bAlpha and format(' fill-opacity="%.2f" stroke-opacity="%.2f"', bAlpha, bAlpha) or ''

				if v1 and v2 then
					if isPolBar then
						-- 極座標扇形長條圖 (Polar Bar / Rose Chart Wedge)
						local cx, cy = mapX(0), mapY(0)
						local th1 = (s.align == "edge") and v1 or (v1 - w * 0.5)
						local th2 = (s.align == "edge") and (v1 + w) or (v1 + w * 0.5)
						local r0Px = max(0, (bot / xSpan) * plotW)
						local r1Px = max(0, ((bot + v2) / xSpan) * plotW)
						if r1Px > r0Px then
							local largeArc = (abs(w) > pi) and 1 or 0
							local sweepOuter = (w >= 0) and 0 or 1
							local pathD
							if r0Px <= 0.5 then
								local x1, y1 = cx + r1Px * cos(th1), cy - r1Px * sin(th1)
								local x2, y2 = cx + r1Px * cos(th2), cy - r1Px * sin(th2)
								pathD = format(
									"M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
									cx, cy, x1, y1, r1Px, r1Px, largeArc, sweepOuter, x2, y2
								)
							else
								local x1, y1 = cx + r1Px * cos(th1), cy - r1Px * sin(th1)
								local x2, y2 = cx + r1Px * cos(th2), cy - r1Px * sin(th2)
								local ix1, iy1 = cx + r0Px * cos(th2), cy - r0Px * sin(th2)
								local ix2, iy2 = cx + r0Px * cos(th1), cy - r0Px * sin(th1)
								local sweepInner = (w >= 0) and 1 or 0
								pathD = format(
									"M %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
									x1, y1, r1Px, r1Px, largeArc, sweepOuter, x2, y2,
									ix1, iy1, r0Px, r0Px, largeArc, sweepInner, ix2, iy2
								)
							end
							buf[#buf + 1] = format(
								'<path d="%s" fill="%s" stroke="%s" stroke-width="%.1f" stroke-linejoin="round"%s/>',
								pathD, bCol, eCol, lw, opAttr
							)
							local midTh = 0.5 * (th1 + th2)
							local midRPx = (r0Px > 0.5) and (0.5 * (r0Px + r1Px)) or (r1Px * 0.6)
							addHoverPoint(
								overlays,
								cx + midRPx * cos(midTh), cy - midRPx * sin(midTh),
								max(10, min(18, r1Px * 0.18)),
								bCol, sName or format("θ=%.2f", v1), format(": r=%g (w=%g)", v2, w), plotT
							)
						end
					elseif s.kind == "bar" or s.kind == "hist" then
						local x0 = (s.align == "edge") and mapX(v1) or mapX(v1 - w * 0.5)
						local x1 = (s.align == "edge") and mapX(v1 + w) or mapX(v1 + w * 0.5)
						local y0, y1 = mapY(bot), mapY(bot + v2)
						local rx, ry, rw, rh = min(x0, x1), min(y0, y1), max(0.8, abs(x1 - x0)), max(0.5, abs(y1 - y0))
						buf[#buf + 1] = format(
							'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>',
							rx, ry, rw, rh, bCol, eCol, lw, opAttr
						)
						local catLbl = (ax.xticklabels and ax.xticklabels[j]) or format("%g", v1)
						local bTitle = sName or catLbl
						local bDetail = sName and format(" (%s): %g", catLbl, v2) or format(": %g", v2)
						addHoverBox(overlays, rx, ry, rw, rh, bCol, bTitle, bDetail, plotT)
					else
						local y0 = (s.align == "edge") and mapY(v1) or mapY(v1 - w * 0.5)
						local y1 = (s.align == "edge") and mapY(v1 + w) or mapY(v1 + w * 0.5)
						local x0, x1 = mapX(bot), mapX(bot + v2)
						local rx, ry, rw, rh = min(x0, x1), min(y0, y1), max(0.5, abs(x1 - x0)), max(0.8, abs(y1 - y0))
						buf[#buf + 1] = format(
							'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>',
							rx, ry, rw, rh, bCol, eCol, lw, opAttr
						)
						local catLbl = (ax.yticklabels and ax.yticklabels[j]) or format("%g", v1)
						local bTitle = catLbl or sName or ("Item " .. j)
						local bDetail = (bIsTbl or abs(bot) > 1e-9) and format(": %g ~ %g", bot, bot + v2) or format(": %g", v2)
						addHoverBox(overlays, rx, ry, rw, rh, bCol, bTitle, bDetail, plotT)
					end
				end
			end

		elseif s.kind == "broken_barh" then
			local xranges = s.xranges or {}
			local y0 = tonumber(s.yrange and s.yrange[1]) or 0
			local yh = tonumber(s.yrange and s.yrange[2]) or 1
			local fCols = s.facecolors
			local eCol = s.edgecolor and parseColor(s.edgecolor) or "none"
			local lw = tonumber(s.linewidth) or 0.8
			local opAttr = alpha and format(' fill-opacity="%.2f"', alpha) or ''
			local py0, py1 = mapY(y0), mapY(y0 + yh)
			local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or "Task"
			for j, xr in ipairs(xranges) do
				local xstart, xwidth = tonumber(xr[1]), tonumber(xr[2])
				if xstart and xwidth then
					local cHex = strokeColor
					if type(fCols) == "table" and #fCols > 0 then
						cHex = parseColor(fCols[(j - 1) % #fCols + 1], strokeColor)
					elseif type(fCols) == "string" then
						cHex = parseColor(fCols, strokeColor)
					end
					local px0, px1 = mapX(xstart), mapX(xstart + xwidth)
					local rx, ry, rw, rh = min(px0, px1), min(py0, py1), max(0.5, abs(px1 - px0)), max(0.8, abs(py1 - py0))
					buf[#buf + 1] = format(
						'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>',
						rx, ry, rw, rh, cHex, eCol, lw, opAttr
					)
					addHoverBox(overlays, rx, ry, rw, rh, cHex, sName, format(": %g ~ %g", xstart, xstart + xwidth), plotT)
				end
			end

		elseif s.kind == "boxplot" then
			local vert = (s.vert ~= false)
			local boxW = tonumber(s.widths) or 0.5
			local patchArtist = (s.patch_artist == true)
			local showMeans = (s.showmeans == true)
			local showFliers = (s.showfliers ~= false)
			local fillCol = patchArtist and strokeColor or "#ffffff"
			local fillOp = patchArtist and (alpha or 0.65) or 1.0
			local bdrCol = patchArtist and RendererUtils.shadeColor(strokeColor, 0.65) or "#2b2b2b"
			local medCol = "#ff7f0e"

			for k, item in ipairs(s._stats or {}) do
				local pos, st = item.pos, item.st
				local hw = boxW * 0.5
				local capW = hw * 0.55
				local grpName = (vert and ax.xticklabels and ax.xticklabels[k]) or ((not vert) and ax.yticklabels and ax.yticklabels[k]) or ("Group " .. k)
				local statDetail = format(" | Med: %g (Q1: %g, Q3: %g, Min: %g, Max: %g)", st.med, st.q1, st.q3, st.wLow, st.wHigh)
				if vert then
					local px = mapX(pos)
					local pxL, pxR = mapX(pos - hw), mapX(pos + hw)
					local cL, cR = mapX(pos - capW), mapX(pos + capW)
					local pyQ1, pyMed, pyQ3 = mapY(st.q1), mapY(st.med), mapY(st.q3)
					local pyWL, pyWH = mapY(st.wLow), mapY(st.wHigh)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', px, pyQ1, px, pyWL, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', px, pyQ3, px, pyWH, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyWL, cR, pyWL, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyWH, cR, pyWH, bdrCol)
					buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="1.3"/>', min(pxL, pxR), min(pyQ1, pyQ3), max(2, abs(pxR - pxL)), max(1, abs(pyQ1 - pyQ3)), fillCol, fillOp, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="2.0"/>', pxL, pyMed, pxR, pyMed, medCol)
					if showMeans then
						buf[#buf + 1] = renderSymbol("triangle-up", px, mapY(st.mean), 3.5, "#2ca02c", "#2ca02c", 0.5, 1, customState)
					end
					if showFliers then
						for _, fv in ipairs(st.fliers) do
							buf[#buf + 1] = renderSymbol("circle", px, mapY(fv), 2.8, "none", "#333333", 1.1, 0.85, customState)
						end
					end
					addHoverBox(overlays, min(pxL, pxR), min(pyWL, pyWH), abs(pxR - pxL), abs(pyWL - pyWH), strokeColor, grpName, statDetail, plotT)
				else
					local py = mapY(pos)
					local pyT, pyB = mapY(pos + hw), mapY(pos - hw)
					local cT, cB = mapY(pos + capW), mapY(pos - capW)
					local pxQ1, pxMed, pxQ3 = mapX(st.q1), mapX(st.med), mapX(st.q3)
					local pxWL, pxWH = mapX(st.wLow), mapX(st.wHigh)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', pxQ1, py, pxWL, py, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', pxQ3, py, pxWH, py, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxWL, cT, pxWL, cB, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxWH, cT, pxWH, cB, bdrCol)
					buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="1.3"/>', min(pxQ1, pxQ3), min(pyT, pyB), max(1, abs(pxQ3 - pxQ1)), max(2, abs(pyB - pyT)), fillCol, fillOp, bdrCol)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="2.0"/>', pxMed, pyT, pxMed, pyB, medCol)
					if showMeans then
						buf[#buf + 1] = renderSymbol("triangle-right", mapX(st.mean), py, 3.5, "#2ca02c", "#2ca02c", 0.5, 1, customState)
					end
					if showFliers then
						for _, fv in ipairs(st.fliers) do
							buf[#buf + 1] = renderSymbol("circle", mapX(fv), py, 2.8, "none", "#333333", 1.1, 0.85, customState)
						end
					end
					addHoverBox(overlays, min(pxWL, pxWH), min(pyT, pyB), abs(pxWH - pxWL), abs(pyB - pyT), strokeColor, grpName, statDetail, plotT)
				end
			end

		elseif s.kind == "violinplot" then
			local vert = (s.vert ~= false)
			local vWidth = tonumber(s.widths) or 0.5
			local showMeans = (s.showmeans == true)
			local showMedians = (s.showmedians == true)
			local showExtrema = (s.showextrema ~= false)
			local vAlpha = alpha or 0.50
			local bdrCol = RendererUtils.shadeColor(strokeColor, 0.72)

			for k, item in ipairs(s._stats or {}) do
				local pos, st = item.pos, item.st
				local kde = st.kde or {}
				local hw = vWidth * 0.5
				local grpName = (vert and ax.xticklabels and ax.xticklabels[k]) or ((not vert) and ax.yticklabels and ax.yticklabels[k]) or ("Violin " .. k)
				local statDetail = format(" | Med: %g (Mean: %.2f, Min: %g, Max: %g)", st.med, st.mean, st.min, st.max)
				if #kde >= 2 then
					local pts = {}
					if vert then
						for idx_k = 1, #kde do
							pts[#pts + 1] = format("%.2f,%.2f", mapX(pos - kde[idx_k].dens * hw), mapY(kde[idx_k].val))
						end
						for idx_k = #kde, 1, -1 do
							pts[#pts + 1] = format("%.2f,%.2f", mapX(pos + kde[idx_k].dens * hw), mapY(kde[idx_k].val))
						end
					else
						for idx_k = 1, #kde do
							pts[#pts + 1] = format("%.2f,%.2f", mapX(kde[idx_k].val), mapY(pos - kde[idx_k].dens * hw))
						end
						for idx_k = #kde, 1, -1 do
							pts[#pts + 1] = format("%.2f,%.2f", mapX(kde[idx_k].val), mapY(pos + kde[idx_k].dens * hw))
						end
					end
					buf[#buf + 1] = format('<polygon points="%s" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="1.2"/>', table.concat(pts, " "), strokeColor, vAlpha, bdrCol)

					if vert then
						local px = mapX(pos)
						local pxL, pxR = mapX(pos - hw), mapX(pos + hw)
						local cL, cR = mapX(pos - hw * 0.35), mapX(pos + hw * 0.35)
						local pyMin, pyMax = mapY(st.min), mapY(st.max)
						if showExtrema then
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2"/>', px, pyMin, px, pyMax, bdrCol)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyMin, cR, pyMin, bdrCol)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyMax, cR, pyMax, bdrCol)
						end
						if showMedians then
							local pyMed = mapY(st.med)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#222222" stroke-width="2.0"/>', cL, pyMed, cR, pyMed)
						end
						if showMeans then
							local pyMean = mapY(st.mean)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d62728" stroke-width="1.8"/>', cL, pyMean, cR, pyMean)
						end
						addHoverBox(overlays, min(pxL, pxR), min(pyMin, pyMax), abs(pxR - pxL), abs(pyMin - pyMax), strokeColor, grpName, statDetail, plotT)
					else
						local py = mapY(pos)
						local pyT, pyB = mapY(pos + hw), mapY(pos - hw)
						local cT, cB = mapY(pos + hw * 0.35), mapY(pos - hw * 0.35)
						local pxMin, pxMax = mapX(st.min), mapX(st.max)
						if showExtrema then
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2"/>', pxMin, py, pxMax, py, bdrCol)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxMin, cT, pxMin, cB, bdrCol)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxMax, cT, pxMax, cB, bdrCol)
						end
						if showMedians then
							local pxMed = mapX(st.med)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#222222" stroke-width="2.0"/>', pxMed, cT, pxMed, cB)
						end
						if showMeans then
							local pxMean = mapX(st.mean)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d62728" stroke-width="1.8"/>', pxMean, cT, pxMean, cB)
						end
						addHoverBox(overlays, min(pxMin, pxMax), min(pyT, pyB), abs(pxMax - pxMin), abs(pyB - pyT), strokeColor, grpName, statDetail, plotT)
					end
				end
			end

		elseif s.kind == "hline" or s.kind == "vline" then
			local posVal = tonumber(s.pos) or 0
			local fMin = tonumber(s.min_frac) or 0
			local fMax = tonumber(s.max_frac) or 1
			local lw = tonumber(s.linewidth) or 1.5
			local dash = getDashArray(s.linestyle)
			local dashAttr = dash and format(' stroke-dasharray="%s"', dash) or ''
			local opAttr = alpha and format(' stroke-opacity="%.2f"', alpha) or ''
			if s.kind == "hline" then
				local py = mapY(posVal)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s%s/>', plotL + fMin * plotW, py, plotL + fMax * plotW, py, strokeColor, lw, dashAttr, opAttr)
			else
				local px = mapX(posVal)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s%s/>', px, plotT + (1 - fMax) * plotH, px, plotT + (1 - fMin) * plotH, strokeColor, lw, dashAttr, opAttr)
			end

		elseif s.kind == "line" or s.kind == "errorbar" then
			local cxArr, cyArr = s._cx or {}, s._cy or {}
			local rawX, rawY = s.x or {}, s.y or {}
			local n_pts = min(#cxArr, #cyArr)
			local sLW = (s.linestyle == "none") and 0 or (tonumber(s.linewidth) or 2.0)
			local sInterp = (isPolarCoord or s.polar) and "linear" or (s.interpolate or "linear")
			local dash = getDashArray(s.linestyle)
			local dashAttr = dash and format(' stroke-dasharray="%s"', dash) or ''
			local opAttr = alpha and format(' stroke-opacity="%.2f"', alpha) or ''

			if s.kind == "errorbar" then
				local eCol = parseColor(s.ecolor, strokeColor)
				local eLW = tonumber(s.elinewidth) or max(1.0, sLW * 0.75)
				local capSz = tonumber(s.capsize) or 3.5
				for j = 1, n_pts do
					local vx, vy = cxArr[j], cyArr[j]
					if vx and vy then
						local px, py = mapX(vx), mapY(vy)
						if s.yerr_low and s.yerr_high then
							local yl = tonumber(s.yerr_low[j]) or 0
							local yh = tonumber(s.yerr_high[j]) or 0
							local py1, py2 = mapY(vy - yl), mapY(vy + yh)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px, py1, px, py2, eCol, eLW, opAttr)
							if capSz > 0 then
								buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px - capSz, py1, px + capSz, py1, eCol, eLW, opAttr)
								buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px - capSz, py2, px + capSz, py2, eCol, eLW, opAttr)
							end
						end
						if s.xerr_low and s.xerr_high then
							local xl = tonumber(s.xerr_low[j]) or 0
							local xh = tonumber(s.xerr_high[j]) or 0
							local px1, px2 = mapX(vx - xl), mapX(vx + xh)
							buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px1, py, px2, py, eCol, eLW, opAttr)
							if capSz > 0 then
								buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px1, py - capSz, px1, py + capSz, eCol, eLW, opAttr)
								buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px2, py - capSz, px2, py + capSz, eCol, eLW, opAttr)
							end
						end
					end
				end
			end

			local userJumpTh = tonumber(s.jump_threshold or ax.jump_threshold)
			local jumpCutSpan = userJumpTh or (ySpan * 1.5)
			local jumpType = string.lower(tostring(s.jump_type or ax.jump_type or "auto"))
			local showJumpCircles = (s.mark_jumps == true) or (ax.mark_jumps == true) or (s.jumps ~= nil)
			local jumps = (type(s.jumps) == "table" and s.jumps) or ((showJumpCircles or userJumpTh) and detectArrayJumps(cxArr, cyArr, ySpan, userJumpTh, jumpType)) or {}
			local jumpByIdx = {}
			for _, jmp in ipairs(jumps) do
				if jmp.splitIdx then jumpByIdx[jmp.splitIdx] = jmp end
			end

			if sLW > 0 then
				local segments, curr_seg = {}, {}
				local prev_vy = nil
				for j = 1, n_pts do
					local vx, vy = cxArr[j], cyArr[j]
					local isValid = vx and vy and vx == vx and vy == vy and vy >= (yMin - yMargin) and vy <= (yMax + yMargin)
					if isValid then
						local isAutoCut = (not isPolarCoord and not s.polar and prev_vy) and (
							(userJumpTh and abs(vy - prev_vy) >= userJumpTh) or
							(abs(vy - prev_vy) > jumpCutSpan and ((vy - yMin) * (prev_vy - yMin) < 0 or (vy > yMax and prev_vy < yMin) or (vy < yMin and prev_vy > yMax)))
						)
						if isAutoCut then
							if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
							curr_seg = {}
						end
						curr_seg[#curr_seg + 1] = { mapX(vx), mapY(vy) }
						prev_vy = vy
						if jumpByIdx[j] then
							local jmp = jumpByIdx[j]
							curr_seg[#curr_seg] = { mapX(jmp.x), mapY(jmp.yL) }
							if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
							curr_seg = { { mapX(jmp.x), mapY(jmp.yR) } }
						end
					else
						if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
						curr_seg, prev_vy = {}, nil
					end
				end
				if #curr_seg > 1 then segments[#segments + 1] = curr_seg end

				for _, seg in ipairs(segments) do
					local path_d = buildSegmentPath(seg, sInterp)
					buf[#buf + 1] = format('<path d="%s" class="%s" stroke="%s" stroke-width="%.2f"%s%s/>', path_d, lineClass, strokeColor, sLW, dashAttr, opAttr)
				end
			end

			if showJumpCircles and #jumps > 0 then
				local jRadius = max(3.2, (sLW > 0 and sLW or 2.0) * 1.4)
				local jStrokeW = max(1.5, (sLW > 0 and sLW or 2.0) * 0.8)
				for _, jmp in ipairs(jumps) do
					local px = mapX(jmp.x)
					local pyL, pyR = mapY(jmp.yL), mapY(jmp.yR)
					local solidY, hollowY1, hollowY2 = nil, nil, nil
					if jumpType == "left" or jumpType == "left-continuous" then
						solidY, hollowY1 = pyL, pyR
					elseif jumpType == "right" or jumpType == "right-continuous" then
						hollowY1, solidY = pyL, pyR
					else
						if jmp.y0 ~= nil and abs(jmp.y0 - jmp.yL) <= 1e-4 then solidY, hollowY1 = pyL, pyR
						elseif jmp.y0 ~= nil and abs(jmp.y0 - jmp.yR) <= 1e-4 then hollowY1, solidY = pyL, pyR
						elseif jmp.y0 ~= nil then hollowY1, hollowY2, solidY = pyL, pyR, mapY(jmp.y0)
						else hollowY1, solidY = pyL, pyR end
					end
					if hollowY1 then buf[#buf + 1] = renderSymbol("circle", px, hollowY1, jRadius, "#f8f8f8", strokeColor, jStrokeW, alpha, customState) end
					if hollowY2 then buf[#buf + 1] = renderSymbol("circle", px, hollowY2, jRadius, "#f8f8f8", strokeColor, jStrokeW, alpha, customState) end
					if solidY then buf[#buf + 1] = renderSymbol("circle", px, solidY, jRadius, strokeColor, strokeColor, 1.0, alpha, customState) end
				end
			end

			local hasMarker = (s.marker ~= nil and s.marker ~= "" and s.marker ~= "none") or (s.kind == "errorbar" and s.marker ~= "none")
			local marks = s.marks or {}
			local showVals = (s.show_mark_values == true) or (ax.show_mark_values == true)
			local mvFmt = s.mark_value_format or ax.mark_value_format
			local mvDir = string.lower(tostring(s.mark_value_dir or ax.mark_value_dir or "top"))
			local symR = tonumber(s.markersize) or max(3.2, (sLW > 0 and sLW or 2.0) * 1.35)
			local symShape = s.marker or "circle"
			local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or ("y" .. i)

			local function drawMarkAndVal(vx, vy, origIdx)
				local px, py = mapX(vx), mapY(vy)
				if hasMarker or #marks > 0 or sLW <= 0 then
					buf[#buf + 1] = renderSymbol(symShape, px, py, symR, strokeColor, strokeColor, 0, alpha, customState)
				end
				if showVals and py >= plotT - 2 and py <= plotT + plotH + 2 then
					local valStr = mvFmt and safeFormat(mvFmt, vy) or formatTickValue(vy, yUnit, yUnitLabel, tickFracY, yFmt)
					local dir = mvDir
					if dir == "top" and (py - plotT < 15) then dir = "bottom"
					elseif dir == "bottom" and (plotT + plotH - py < 15) then dir = "top" end
					local tx, ty, anchor = px, py, "middle"
					local offset = symR + 4.5
					if dir == "bottom" then ty = py + offset + 7.5
					elseif dir == "left" then tx, ty, anchor = px - offset - 2, py + 3.5, "end"
					elseif dir == "right" then tx, ty, anchor = px + offset + 2, py + 3.5, "start"
					else ty = py - offset end
					if anchor == "middle" then
						if tx - plotL < 14 then anchor = "start"; tx = max(plotL + 2, tx - 4)
						elseif plotL + plotW - tx < 14 then anchor = "end"; tx = min(plotL + plotW - 2, tx + 4) end
					end
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-val">%s</text>', tx, ty, anchor, esc(valStr))
				end
				-- 為折線/雷達點建立 Hover 光圈與 Tooltip
				if px >= plotL - 2 and px <= plotL + plotW + 2 and py >= plotT - 2 and py <= plotT + plotH + 2 then
					local xDisp = (origIdx and ax.xticklabels and ax.xticklabels[origIdx]) or formatTickValue(origIdx and tonumber(rawX[origIdx]) or vx, xUnit, xUnitLabel, tickFracX, xFmt)
					local yDisp = formatTickValue(origIdx and tonumber(rawY[origIdx]) or vy, yUnit, yUnitLabel, tickFracY, yFmt)
					addHoverPoint(overlays, px, py, max(8.5, symR + 5), strokeColor, sName, format(" (%s): %s", xDisp, yDisp), plotT)
				end
			end

			if #marks > 0 then
				for _, mk in ipairs(marks) do drawMarkAndVal(mk.x, mk.y, nil) end
			else
				-- 若點數 <= 65 全數建立 Hover 點;若為高密度連續曲線則自動每隔 step 抽樣建立 Hover 點
				local hoverStep = (n_pts <= 65 or hasMarker or sLW <= 0) and 1 or max(1, floor(n_pts / 40))
				for j = 1, n_pts do
					local vx, vy = cxArr[j], cyArr[j]
					if vx and vy and ((j - 1) % hoverStep == 0 or j == n_pts) then
						drawMarkAndVal(vx, vy, j)
					end
				end
			end

		elseif s.kind == "scatter" then
			local cxArr, cyArr = s._cx or {}, s._cy or {}
			local rawX, rawY = s.x or {}, s.y or {}
			local n_pts = min(#cxArr, #cyArr)
			local cArr = s.c_array
			local sArr = s.s_array
			local baseR = tonumber(s.markersize) or 3.8
			local shape = s.marker or "circle"
			local cmap = s.cmap or ax.colormap or "viridis"
			local edgeCol = s.edgecolor and parseColor(s.edgecolor) or nil
			local edgeLW = tonumber(s.linewidth) or (edgeCol and 0.8 or 0)
			local showVals = (s.show_mark_values == true) or (ax.show_mark_values == true)
			local mvFmt = s.mark_value_format or ax.mark_value_format
			local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or nil

			local cMin, cMax = math.huge, -math.huge
			if cArr and #cArr > 0 then
				for _, cv in ipairs(cArr) do
					if type(cv) == "number" and cv == cv then
						if cv < cMin then cMin = cv end
						if cv > cMax then cMax = cv end
					end
				end
			end
			if cMin < math.huge then
				lastCmap, lastVMin, lastVMax = cmap, cMin, cMax
			end
			local cSpan = (cMax > cMin) and (cMax - cMin) or 1

			for j = 1, n_pts do
				local vx, vy = cxArr[j], cyArr[j]
				if vx and vy then
					local px, py = mapX(vx), mapY(vy)
					local ptCol = strokeColor
					if cArr and cArr[j] ~= nil then
						if type(cArr[j]) == "number" then
							ptCol = sampleColormap(cmap, (cArr[j] - cMin) / cSpan, 1.0)
						else
							ptCol = parseColor(cArr[j], strokeColor)
						end
					end
					local ptR = baseR
					local rawSize = sArr and tonumber(sArr[j]) or nil
					if rawSize then
						ptR = max(1.5, sqrt(rawSize) * 0.58)
					end
					buf[#buf + 1] = renderSymbol(shape, px, py, ptR, ptCol, edgeCol or ptCol, edgeLW, alpha, customState)
					if showVals then
						local valStr = mvFmt and safeFormat(mvFmt, vy) or formatTickValue(vy, yUnit, yUnitLabel, tickFracY, yFmt)
						local ty = (py - plotT < 15) and (py + ptR + 11) or (py - ptR - 4)
						buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-val">%s</text>', px, ty, esc(valStr))
					end
					-- 散佈圖 / 氣泡圖 Hover 光圈:光圈半徑隨氣泡 ptR 自動放大 (ptR + 4.5)
					if px >= plotL - 2 and px <= plotL + plotW + 2 and py >= plotT - 2 and py <= plotT + plotH + 2 then
						local xDisp = formatTickValue(tonumber(rawX[j]) or vx, xUnit, xUnitLabel, tickFracX, xFmt)
						local yDisp = formatTickValue(tonumber(rawY[j]) or vy, yUnit, yUnitLabel, tickFracY, yFmt)
						local bTitle = sName or xDisp
						local bDetail = sName and format(" (%s): %s", xDisp, yDisp) or format(": %s", yDisp)
						if rawSize then bDetail = bDetail .. format(" (size: %g)", rawSize) end
						addHoverPoint(overlays, px, py, max(8.5, ptR + 4.5), ptCol, bTitle, bDetail, plotT)
					end
				end
			end
		end
	end
	buf[#buf + 1] = '</g>'

	if isPolarCircle then
		if polarOuterCircleSvg then buf[#buf + 1] = polarOuterCircleSvg end
	else
		buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bdr"/>', plotL, plotT, plotW, plotH)
	end

	-- 2b. 若開啟了熱圖色階條 (colorbar),繪製於繪圖區右側
	if hasColorbar then
		local cbX = plotL + plotW + 10
		local cbW = 11
		local cbSteps = 32
		local stepH = plotH / cbSteps
		for st = 1, cbSteps do
			local tNorm = 1.0 - (st - 0.5) / cbSteps
			local colHex = sampleColormap(lastCmap, tNorm, 1.0)
			buf[#buf + 1] = format('<rect x="%.1f" y="%.2f" width="%.1f" height="%.2f" fill="%s"/>', cbX, plotT + (st - 1) * stepH, cbW, stepH + 0.4, colHex)
		end
		buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bdr"/>', cbX, plotT, cbW, plotH)
		local cbTicks = buildAxisTicks(lastVMin, lastVMax, nil, nil, false, false, 4)
		local vSpan = max(1e-9, lastVMax - lastVMin)
		for _, tv in ipairs(cbTicks) do
			if tv >= lastVMin - 1e-6 and tv <= lastVMax + 1e-6 then
				local py = plotT + plotH - ((tv - lastVMin) / vSpan) * plotH
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#333" stroke-width="1"/>', cbX + cbW, py, cbX + cbW + 3, py)
				buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" class="mpl-tk">%s</text>', cbX + cbW + 5, py + 3.5, esc(format("%g", tv)))
			end
		end
	end

	-- 3. 繪製自訂文字與箭頭註解 (ax.text / ax.annotate)
	for _, tItem in ipairs(ax.texts or {}) do
		local vx, vy = tonumber(tItem.x) or 0, tonumber(tItem.y) or 0
		if isPolarCoord then
			local th, r = vx, vy
			vx, vy = r * cos(th), r * sin(th)
		end
		local tx, ty = mapX(vx), mapY(vy)
		local tCol = parseColor(tItem.color, "#222222")
		local fs = tonumber(tItem.fontsize) or 11
		if tItem.arrow_to then
			local ax0, ay0 = tonumber(tItem.arrow_to[1]) or 0, tonumber(tItem.arrow_to[2]) or 0
			if isPolarCoord then ax0, ay0 = ay0 * cos(ax0), ay0 * sin(ax0) end
			local axTo, ayTo = mapX(ax0), mapY(ay0)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2"/>', tx, ty, axTo, ayTo, tCol)
			buf[#buf + 1] = renderSymbol("circle", axTo, ayTo, 2.5, tCol, tCol, 0, 1, customState)
		end
		if useHtmlLabels then
			local alignCls = (tItem.ha == "center") and "mpl-c" or ((tItem.ha == "right") and "mpl-r" or "mpl-v")
			overlays[#overlays + 1] = format('<span class="mpl-ov %s" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;color:%s;">%s</span>', alignCls, tx, ty, fs, tCol, tostring(tItem.text or ""))
		else
			local anc = (tItem.ha == "center") and "middle" or ((tItem.ha == "right") and "end" or "start")
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-family="sans-serif" font-size="%.1fpx" fill="%s">%s</text>', tx, ty + 3.5, anc, fs, tCol, esc(tItem.text))
		end
	end

	-- 4. 繪製子圖標題與 X/Y 軸標籤 (HTML 疊加層)
	appendSubplotTitle(ax, box, plotL, plotW, plotT, buf, overlays, useHtmlLabels)

	if xLabel and xLabel ~= "" then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', plotL + plotW * 0.5, plotT + plotH + 27, tostring(xLabel))
		else
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" fill="#2b2b2b">%s</text>', plotL + plotW * 0.5, plotT + plotH + 31, esc(xLabel))
		end
	end
	if yLabel and yLabel ~= "" then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="mpl-ov mpl-rot-y mpl-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', box.x + 12, plotT + plotH * 0.5, tostring(yLabel))
		else
			local xc, yc = box.x + 13, plotT + plotH * 0.5
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" fill="#2b2b2b" transform="rotate(-90, %.1f, %.1f)">%s</text>', xc, yc, xc, yc, esc(yLabel))
		end
	end

	-- 5. 繪製圖例 (若為圓形極座標/雷達圖,自動貼齊子圖右上角空白處避免遮擋圓周標籤)
	if showLegend and legItems then
		local legX, legY
		if isOutsideLegend then
			legX, legY = plotL + plotW + 8, plotT
		elseif isPolarCircle then
			legX, legY = box.x + box.w - legBoxW - 6, box.y + 8
		elseif legLoc == "upper left" or legLoc == "top-left" then
			legX, legY = plotL + 8, plotT + 8
		elseif legLoc == "lower right" or legLoc == "bottom-right" then
			legX, legY = plotL + plotW - legBoxW - 8, plotT + plotH - legBoxH - 8
		elseif legLoc == "lower left" or legLoc == "bottom-left" then
			legX, legY = plotL + 8, plotT + plotH - legBoxH - 8
		else
			legX, legY = plotL + plotW - legBoxW - 8, plotT + 8
		end
		appendAxesLegend(buf, overlays, legItems, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
	end

	return clipTag
end

-- ==========================================
-- 5. 3D 空間座標子圖渲染器 (projection = '3d')
-- ==========================================
local function render3DAxes(ax, box, buf, overlays, customState, useHtmlLabels)
	local palette = ax.palette or DEFAULT_COLORS
	local series_list = ax.series or {}
	local showLegend = (ax.show_legend == true)
	local legItems, legBoxW, legBoxH, legFontSize = nil, 0, 0, 11.0
	if showLegend then
		legItems, legBoxW, legBoxH, legFontSize = prepareAxesLegend(ax, palette, useHtmlLabels)
	end
	local legLoc = string.lower(tostring(ax.legend_loc or "upper right"))
	local isOutsideLegend = showLegend and legItems and (legLoc == "right" or legLoc == "outside")

	local hasTitle = (ax.title and ax.title ~= "")
	local padL = 34
	local padR = isOutsideLegend and (legBoxW + 12) or 18
	local padT = hasTitle and 28 or 14
	local padB = 24

	local plotW = max(100, box.w - padL - padR)
	local plotH = max(100, box.h - padT - padB)
	local plotL = box.x + padL
	local plotT = box.y + padT

	local cx = plotL + plotW * 0.52
	local cy = plotT + plotH * 0.50
	local plotRadius = min(plotW, plotH) * 0.37

	local bounds = MathEngine.compute3DBounds(series_list, ax)
	local cam = MathEngine.buildCamera(bounds, { cx, cy }, plotRadius, {
		elev = ax.elev or ax.view_phi,
		azim = ax.azim or ax.view_theta,
		roll = ax.roll or ax.view_roll,
		proj_type = ax.proj_type or ax.projection_type,
		view_dist = ax.view_dist,
		equal_scale = (ax.equal_scale == true) or (ax.aspect == "equal"),
		box_aspect = ax.box_aspect
	})
	local boxGeo = MathEngine.buildBoxGeometry(cam)

	local xTicks = ax.xticks or getComplexTicks(bounds.xMin, bounds.xMax, ax.x_tick_step, 5)
	local yTicks = ax.yticks or getComplexTicks(bounds.yMin, bounds.yMax, ax.y_tick_step, 5)
	local zTicks = ax.zticks or getComplexTicks(bounds.zMin, bounds.zMax, ax.z_tick_step, 5)
	local showAxes = (ax.show_axes ~= false)
	local showGrid = (ax.grid ~= false)
	local bx, by, bz = cam.boxScale[1], cam.boxScale[2], cam.boxScale[3]

	if showAxes then
		for _, face in ipairs(boxGeo.backFaces) do
			local c = face.corners
			local x1, y1 = cam.projectNorm(c[1][1], c[1][2], c[1][3])
			local x2, y2 = cam.projectNorm(c[2][1], c[2][2], c[2][3])
			local x3, y3 = cam.projectNorm(c[3][1], c[3][2], c[3][3])
			local x4, y4 = cam.projectNorm(c[4][1], c[4][2], c[4][3])
			buf[#buf + 1] = format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#f5f5f5" stroke="#dcdcdc" stroke-width="0.9"/>', x1, y1, x2, y2, x3, y3, x4, y4)
		end

		if showGrid then
			for _, xv in ipairs(xTicks) do
				local nx = cam.toNorm(xv, bounds.yMin, bounds.zMin)
				local x1, y1 = cam.projectNorm(nx, -by, boxGeo.backZ)
				local x2, y2 = cam.projectNorm(nx, by, boxGeo.backZ)
				local x3, y3 = cam.projectNorm(nx, boxGeo.backY, -bz)
				local x4, y4 = cam.projectNorm(nx, boxGeo.backY, bz)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x1, y1, x2, y2)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x3, y3, x4, y4)
			end
			for _, yv in ipairs(yTicks) do
				local _, ny = cam.toNorm(bounds.xMin, yv, bounds.zMin)
				local x1, y1 = cam.projectNorm(-bx, ny, boxGeo.backZ)
				local x2, y2 = cam.projectNorm(bx, ny, boxGeo.backZ)
				local x3, y3 = cam.projectNorm(boxGeo.backX, ny, -bz)
				local x4, y4 = cam.projectNorm(boxGeo.backX, ny, bz)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x1, y1, x2, y2)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x3, y3, x4, y4)
			end
			for _, zv in ipairs(zTicks) do
				local _, _, nz = cam.toNorm(bounds.xMin, bounds.yMin, zv)
				local x1, y1 = cam.projectNorm(-bx, boxGeo.backY, nz)
				local x2, y2 = cam.projectNorm(bx, boxGeo.backY, nz)
				local x3, y3 = cam.projectNorm(boxGeo.backX, -by, nz)
				local x4, y4 = cam.projectNorm(boxGeo.backX, by, nz)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x1, y1, x2, y2)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x3, y3, x4, y4)
			end
		end
	end

	local primitives = MathEngine.buildScenePrimitives(series_list, bounds, cam, palette, {
		colormap = ax.colormap
	})

	for _, prim in ipairs(primitives) do
		local opAttr = (prim.alpha and prim.alpha < 0.99) and format(' fill-opacity="%.2f" stroke-opacity="%.2f"', prim.alpha, prim.alpha) or ''
		if prim.kind == "quad" then
			buf[#buf + 1] = format('<polygon points="%s" fill="%s" stroke="%s" stroke-width="%.2f" stroke-linejoin="round"%s/>', prim.pts, prim.fill, prim.stroke, prim.strokeW, opAttr)
		elseif prim.kind == "segment" then
			local dashAttr = prim.dash and format(' stroke-dasharray="%s"', prim.dash) or ''
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f" stroke-linecap="round"%s%s/>', prim.x1, prim.y1, prim.x2, prim.y2, prim.stroke, prim.strokeW, dashAttr, opAttr)
		elseif prim.kind == "point" then
			buf[#buf + 1] = renderSymbol(prim.shape, prim.cx, prim.cy, prim.r, prim.fill, prim.stroke, prim.strokeW, prim.alpha, customState)
		end
	end

	if showAxes then
		local axX, axY, axZ = boxGeo.axes.x, boxGeo.axes.y, boxGeo.axes.z
		local tLen = 0.06
		local xLabel = ax.xlabel or "x"
		local yLabel = ax.ylabel or "y"
		local zLabel = ax.zlabel or "z"

		local xx1, xy1 = cam.projectNorm(-bx, axX.yNorm, axX.zNorm)
		local xx2, xy2 = cam.projectNorm(bx, axX.yNorm, axX.zNorm)
		buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-ax"/>', xx1, xy1, xx2, xy2)
		for _, xv in ipairs(xTicks) do
			local nx = cam.toNorm(xv, bounds.yMin, bounds.zMin)
			local p0x, p0y = cam.projectNorm(nx, axX.yNorm, axX.zNorm)
			local p1x, p1y = cam.projectNorm(nx, axX.yNorm + axX.tickVec[2] * tLen, axX.zNorm)
			local plx, ply = cam.projectNorm(nx, axX.yNorm + axX.tickVec[2] * (tLen * 2.8), axX.zNorm)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', p0x, p0y, p1x, p1y)
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', plx, ply + 4, esc(format("%g", xv)))
		end
		if xLabel ~= "" then
			local lx, ly = cam.projectNorm(0, axX.yNorm + axX.tickVec[2] * 0.42, axX.zNorm - 0.06)
			if useHtmlLabels then
				overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', lx, ly, tostring(xLabel))
			else
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" class="mpl-tk">%s</text>', lx, ly, esc(xLabel))
			end
		end

		local yx1, yy1 = cam.projectNorm(axY.xNorm, -by, axY.zNorm)
		local yx2, yy2 = cam.projectNorm(axY.xNorm, by, axY.zNorm)
		buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-ax"/>', yx1, yy1, yx2, yy2)
		for _, yv in ipairs(yTicks) do
			local _, ny = cam.toNorm(bounds.xMin, yv, bounds.zMin)
			local p0x, p0y = cam.projectNorm(axY.xNorm, ny, axY.zNorm)
			local p1x, p1y = cam.projectNorm(axY.xNorm + axY.tickVec[1] * tLen, ny, axY.zNorm)
			local plx, ply = cam.projectNorm(axY.xNorm + axY.tickVec[1] * (tLen * 2.8), ny, axY.zNorm)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', p0x, p0y, p1x, p1y)
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', plx, ply + 4, esc(format("%g", yv)))
		end
		if yLabel ~= "" then
			local lx, ly = cam.projectNorm(axY.xNorm + axY.tickVec[1] * 0.42, 0, axY.zNorm - 0.06)
			if useHtmlLabels then
				overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', lx, ly, tostring(yLabel))
			else
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" class="mpl-tk">%s</text>', lx, ly, esc(yLabel))
			end
		end

		local zx1, zy1 = cam.projectNorm(axZ.xNorm, axZ.yNorm, -bz)
		local zx2, zy2 = cam.projectNorm(axZ.xNorm, axZ.yNorm, bz)
		buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-ax"/>', zx1, zy1, zx2, zy2)
		for _, zv in ipairs(zTicks) do
			local _, _, nz = cam.toNorm(bounds.xMin, bounds.yMin, zv)
			local p0x, p0y = cam.projectNorm(axZ.xNorm, axZ.yNorm, nz)
			local p1x, p1y = cam.projectNorm(axZ.xNorm + axZ.tickVec[1] * tLen, axZ.yNorm + axZ.tickVec[2] * tLen, nz)
			local plx, ply = cam.projectNorm(axZ.xNorm + axZ.tickVec[1] * (tLen * 2.2), axZ.yNorm + axZ.tickVec[2] * (tLen * 2.2), nz)
			buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', p0x, p0y, p1x, p1y)
			buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="mpl-tk">%s</text>', plx - 3, ply + 3.5, esc(format("%g", zv)))
		end
		if zLabel ~= "" then
			local lx, ly = cam.projectNorm(axZ.xNorm + axZ.tickVec[1] * 0.36, axZ.yNorm + axZ.tickVec[2] * 0.36, bz + 0.14)
			if useHtmlLabels then
				overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', max(box.x + 14, lx), max(box.y + 12, ly), tostring(zLabel))
			else
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" class="mpl-tk">%s</text>', max(box.x + 14, lx), max(box.y + 12, ly), esc(zLabel))
			end
		end
	end

	appendSubplotTitle(ax, box, plotL, plotW, plotT, buf, overlays, useHtmlLabels)

	if showLegend and legItems then
		local legX = isOutsideLegend and (plotL + plotW + 6) or (plotL + plotW - legBoxW - 6)
		local legY = plotT + 4
		appendAxesLegend(buf, overlays, legItems, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
	end
end

-- ==========================================
-- 6. 複變函數定義域著色子圖渲染器 (projection = 'complex')
-- ==========================================
local function renderComplexAxes(ax, box, clipId, buf, overlays, useHtmlLabels)
	local cd = ax.complex_data or {}
	local masterAxes = (cd.axes ~= false and ax.show_axes ~= false)
	local showInnerAxes = masterAxes and (cd.inner_axes ~= false)
	local showInnerTicks = masterAxes and (cd.inner_ticks == true)
	local showInnerTickLabels = masterAxes and (cd.inner_tick_labels == true)
	local showOuterTicks = masterAxes and (cd.outer_ticks ~= false)
	local showTickLabels = masterAxes and (cd.tick_labels ~= false)
	local showAxisTitles = masterAxes and (cd.axis_titles ~= false)
	local showGrid = (cd.grid == true) or (ax.grid == true)
	local gridStyle = string.lower(tostring(cd.grid_style or ax.grid_style or "dashed"))
	local imagSuffix = tostring(cd.imag_suffix or "")

	local xTitle = ax.xlabel or cd.xlabel or "實軸 (Re)"
	local yTitle = ax.ylabel or cd.ylabel or "虛軸 (Im)"
	if ax.xlabel == "" or cd.xlabel == "" then xTitle = nil end
	if ax.ylabel == "" or cd.ylabel == "" then yTitle = nil end
	local hasTitle = (ax.title and ax.title ~= "")

	local padL, padB = 8, 8
	local padT = hasTitle and 26 or 10
	local padR = showTickLabels and 14 or 8
	if showOuterTicks then padL = padL + 4; padB = padB + 4 end
	if showTickLabels then padL = padL + 28; padB = padB + 16 end
	if showAxisTitles and yTitle then padL = padL + 18 end
	if showAxisTitles and xTitle then padB = padB + 18 end

	local availW = max(60, box.w - padL - padR)
	local availH = max(60, box.h - padT - padB)
	local plotSize = min(availW, availH)
	local plotL = box.x + padL + (availW - plotSize) * 0.5
	local plotT = box.y + padT + (availH - plotSize) * 0.5

	local xMin = tonumber(cd.x_start) or -5
	local xMax = tonumber(cd.x_end) or 5
	local yMin = tonumber(cd.y_start) or -5
	local yMax = tonumber(cd.y_end) or 5
	if xMin == xMax then xMax = xMin + 1 end
	if yMin == yMax then yMax = yMin + 1 end
	local xSpan, ySpan = xMax - xMin, yMax - yMin

	local function mapX(x) return plotL + ((x - xMin) / xSpan) * plotSize end
	local function mapY(y) return plotT + plotSize - ((y - yMin) / ySpan) * plotSize end

	local clipTag = format('<clipPath id="%s"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath>', clipId, plotL, plotT, plotSize, plotSize)
	buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#7f7f7f"/>', plotL, plotT, plotSize, plotSize)

	local pixels = cd.pixels or {}
	local nRows = #pixels
	local nCols = (nRows > 0 and #pixels[1]) or 1
	local cellW, cellH = plotSize / max(1, nCols), plotSize / max(1, nRows)

	buf[#buf + 1] = '<g shape-rendering="crispEdges">'
	for r = 1, nRows do
		local row = pixels[r]
		local py = plotT + (r - 1) * cellH
		local c = 1
		while c <= nCols do
			local colColor = row[c]
			local runLen = 1
			while (c + runLen <= nCols) and (row[c + runLen] == colColor) do runLen = runLen + 1 end
			buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>', plotL + (c - 1) * cellW, py, runLen * cellW, cellH, colColor)
			c = c + runLen
		end
	end
	buf[#buf + 1] = '</g>'

	local targetTicks = max(4, min(8, floor(plotSize / 32)))
	local xTicks = getComplexTicks(xMin, xMax, ax.x_tick_step, targetTicks)
	local yTicks = getComplexTicks(yMin, yMax, ax.y_tick_step, targetTicks)
	local xUnit = tonumber(ax.x_unit) or 1.0
	local xUnitLabel = mw.text.trim(tostring(ax.x_unit_label or ""))
	local yUnit = tonumber(ax.y_unit) or 1.0
	local yUnitLabel = mw.text.trim(tostring(ax.y_unit_label or ""))
	local useFracX = (ax.tick_fraction == true) or (xUnitLabel ~= "")
	local useFracY = (ax.tick_fraction == true) or (yUnitLabel ~= "")

	buf[#buf + 1] = format('<g clip-path="url(#%s)">', clipId)
	local dashAttr = (gridStyle == "solid") and "" or ' stroke-dasharray="3,2"'
	if showGrid then
		for _, tk in ipairs(xTicks) do
			if not (showInnerAxes and abs(tk) < 1e-10) then
				local px = mapX(tk)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#111111" stroke-opacity="0.38" stroke-width="0.8"%s/>', px, plotT, px, plotT + plotSize, dashAttr)
			end
		end
		for _, tk in ipairs(yTicks) do
			if not (showInnerAxes and abs(tk) < 1e-10) then
				local py = mapY(tk)
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#111111" stroke-opacity="0.38" stroke-width="0.8"%s/>', plotL, py, plotL + plotSize, py, dashAttr)
			end
		end
	end

	local px0, py0 = mapX(0), mapY(0)
	local hasXAxisIn = (yMin <= 0 and yMax >= 0)
	local hasYAxisIn = (xMin <= 0 and xMax >= 0)
	if showInnerAxes then
		if hasXAxisIn then buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#000000" stroke-width="1.3"/>', plotL, py0, plotL + plotSize, py0) end
		if hasYAxisIn then buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#000000" stroke-width="1.3"/>', px0, plotT, px0, plotT + plotSize) end
	end
	if showInnerTicks then
		if hasXAxisIn then
			for _, tk in ipairs(xTicks) do
				if abs(tk) > 1e-10 then
					local px = mapX(tk)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#000000" stroke-width="1.1"/>', px, py0 - 3.5, px, py0 + 3.5)
					if showInnerTickLabels then
						local lbl = formatTickValue(tk, xUnit, xUnitLabel, useFracX, "%g")
						buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-family="sans-serif" font-size="8.5px" fill="#000000">%s</text>', px, py0 + 12, esc(lbl))
					end
				end
			end
		end
		if hasYAxisIn then
			for _, tk in ipairs(yTicks) do
				if abs(tk) > 1e-10 then
					local py = mapY(tk)
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#000000" stroke-width="1.1"/>', px0 - 3.5, py, px0 + 3.5, py)
					if showInnerTickLabels then
						local lbl = formatTickValue(tk, yUnit, yUnitLabel, useFracY, "%g") .. (tk ~= 0 and imagSuffix or "")
						buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-family="sans-serif" font-size="8.5px" fill="#000000">%s</text>', px0 - 5, py + 3, esc(lbl))
					end
				end
			end
		end
	end
	buf[#buf + 1] = '</g>'
	buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bdr"/>', plotL, plotT, plotSize, plotSize)

	local tickLen = 4
	for _, tk in ipairs(xTicks) do
		local px = mapX(tk)
		if px >= plotL - 0.5 and px <= plotL + plotSize + 0.5 then
			if showOuterTicks then buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#2b2b2b" stroke-width="1"/>', px, plotT + plotSize, px, plotT + plotSize + tickLen) end
			if showTickLabels then
				local lbl = formatTickValue(tk, xUnit, xUnitLabel, useFracX, "%g")
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', px, plotT + plotSize + (showOuterTicks and 15 or 12), esc(lbl))
			end
		end
	end
	for _, tk in ipairs(yTicks) do
		local py = mapY(tk)
		if py >= plotT - 0.5 and py <= plotT + plotSize + 0.5 then
			if showOuterTicks then buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', plotL - tickLen, py, plotL, py) end
			if showTickLabels then
				local lbl = formatTickValue(tk, yUnit, yUnitLabel, useFracY, "%g") .. (tk ~= 0 and imagSuffix or "")
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="mpl-tk">%s</text>', plotL - (showOuterTicks and 7 or 4), py + 3.5, esc(lbl))
			end
		end
	end

	appendSubplotTitle(ax, box, plotL, plotSize, plotT, buf, overlays, useHtmlLabels)

	if showAxisTitles and xTitle then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs11" style="left:%.1fpx;top:%.1fpx;">%s</span>', plotL + plotSize * 0.5, plotT + plotSize + 27, tostring(xTitle))
		else
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="#2b2b2b">%s</text>', plotL + plotSize * 0.5, plotT + plotSize + 31, esc(xTitle))
		end
	end
	if showAxisTitles and yTitle then
		local yc = plotT + plotSize * 0.5
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="mpl-ov mpl-rot-y mpl-fs11" style="left:%.1fpx;top:%.1fpx;">%s</span>', plotL - 36, yc, tostring(yTitle))
		else
			local xc = plotL - 35
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="#2b2b2b" transform="rotate(-90, %.1f, %.1f)">%s</text>', xc, yc, xc, yc, esc(yTitle))
		end
	end

	return clipTag
end

-- ==========================================
-- 7. 頂層 Figure 組合渲染器
-- ==========================================
function p.renderFigure(fig)
	if mw.isSubsting() then
		require("Module:沙盒/a2569875/PythonScript/Runtime").raise("NotImplementedError", "圖表與 SVG 渲染功能不支援在 subst (模板展開存成純 wikitext) 模式下使用")
	end
	fig = fig or {}
	local finalW = floor((tonumber(fig.width) or 420) + 0.5)
	local finalH = floor((tonumber(fig.height) or 300) + 0.5)
	local nrows = max(1, floor(tonumber(fig.nrows) or 1))
	local ncols = max(1, floor(tonumber(fig.ncols) or 1))
	local useHtmlLabels = (fig.html_labels ~= false)
	local animateMode = string.lower(tostring(fig.animate or "no"))

	local topOffset = (fig.suptitle and fig.suptitle ~= "") and 28 or 0
	local cellW = finalW / ncols
	local cellH = max(80, (finalH - topOffset) / nrows)

	local buf = {
		"", -- 預留給 <defs>
		format('<rect x="0" y="0" width="%d" height="%d" class="mpl-wbg"/>', finalW, finalH)
	}
	local overlays = {}
	local customState = { count = 0, map = {}, defs = {} }
	local clipDefs = {}

	if fig.suptitle and fig.suptitle ~= "" then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-ttl" style="left:%.1fpx;top:16px;">%s</span>', finalW * 0.5, tostring(fig.suptitle))
		else
			buf[#buf + 1] = format('<text x="%.1f" y="21" text-anchor="middle" font-family="sans-serif" font-size="15px" font-weight="bold" fill="#2b2b2b">%s</text>', finalW * 0.5, esc(fig.suptitle))
		end
	end

	local axes_list = fig.axes_list or {}
	for idx, ax in ipairs(axes_list) do
		local r = tonumber(ax.row) or (floor((idx - 1) / ncols) + 1)
		local c = tonumber(ax.col) or (((idx - 1) % ncols) + 1)
		local box = {
			x = (c - 1) * cellW,
			y = topOffset + (r - 1) * cellH,
			w = cellW,
			h = cellH
		}
		local clipId = "mpl-clip-" .. idx
		local proj = string.lower(tostring(ax.projection or "rectilinear"))

		if proj == "3d" then
			render3DAxes(ax, box, buf, overlays, customState, useHtmlLabels)
		elseif proj == "complex" then
			local cTag = renderComplexAxes(ax, box, clipId, buf, overlays, useHtmlLabels)
			if cTag then clipDefs[#clipDefs + 1] = cTag end
		elseif proj == "pie" then
			renderPieAxes(ax, box, buf, overlays, customState, useHtmlLabels)
		else
			local cTag = render2DAxes(ax, box, clipId, buf, overlays, customState, useHtmlLabels, animateMode)
			if cTag then clipDefs[#clipDefs + 1] = cTag end
		end
	end

	local internalStyle = buildInternalSvgStyle("10px", animateMode)
	buf[1] = format('<defs>%s%s%s</defs>', internalStyle, table.concat(clipDefs), table.concat(customState.defs))

	local svgImg
	if mw and mw.svg and type(mw.svg.new) == "function" then
		svgImg = mw.svg.new()
			:setAttribute('viewBox', format('0 0 %d %d', finalW, finalH))
			:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
			:setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
			:setImgAttribute('width', tostring(finalW))
			:setImgAttribute('height', tostring(finalH))
			:setImgAttribute('class', 'mpl-svg function-graph-svg')
			:setContent(table.concat(buf))
			:toImage()
	else
		svgImg = format(
			'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 %d %d" width="%d" height="%d" class="mpl-svg">%s</svg>',
			finalW, finalH, finalW, finalH, table.concat(buf)
		)
	end

	for i = 1, #overlays do
		overlays[i] = expandMathTags(overlays[i])
	end
	return wrapHybridOutput(svgImg, finalW, finalH, overlays)
end

return p