跳转到内容

模組:FunctionGraph/SVGRenderer

被永久保护的模块
维基百科,自由的百科全书
-- Module:FunctionGraph/SVGRenderer
-- 負責 2D 直角座標、極座標、複變函數定義域著色圖與 3D 曲面/空間曲線之 SVG + HTML 混合排版渲染
local p = {}
local yesno = require('Module:Yesno')
local TextRenderer = require('Module:FunctionGraph/TextRenderer')
local RenderUtils = require('Module:FunctionGraph/RenderUtils')
local MathEngine = require('Module:FunctionGraph/MathEngine')

local format, floor, ceil, abs, max, min, sqrt = string.format, math.floor, math.ceil, math.abs, math.max, math.min, math.sqrt

local DEFAULT_COLORS = RenderUtils.DEFAULT_COLORS
local parseColor = RenderUtils.parseColor
local esc = RenderUtils.esc
local safeFormat = RenderUtils.safeFormat
local hasLocalizableText = RenderUtils.hasLocalizableText
local estimateHtmlTextWidth = RenderUtils.estimateHtmlTextWidth
local buildInternalSvgStyle = RenderUtils.buildInternalSvgStyle
local wrapHybridOutput = RenderUtils.wrapHybridOutput
local parseShapeArray = RenderUtils.parseShapeArray
local renderSymbol = RenderUtils.renderSymbol
local formatTickValue = RenderUtils.formatTickValue
local buildAxisTicks = RenderUtils.buildAxisTicks
local getComplexTicks = RenderUtils.getComplexTicks
local buildSegmentPath = RenderUtils.buildSegmentPath
local getPointXY = RenderUtils.getPointXY

p.renderSymbol = renderSymbol

-- 共用輔助:預先排版圖例項目並計算圖例框尺寸
local function prepareLegendItems(series_list, args, palette, isTemplateCall, defaultLW, shapeList, globalShowSymbols, useHtmlLabels, useMathLegend, showLegend)
	local renderedItems = {}
	local maxItemW = 55
	local totalItemsH = 0
	local legFontSize = isTemplateCall and 11.0 or 12.5

	for i, s in ipairs(series_list) do
		local sLW_raw = args["lineWidth" .. i] or args["linewidth" .. i]
		local sLW = (sLW_raw ~= nil) and (tonumber(string.match(tostring(sLW_raw), "[%d%.]+")) or 0) or defaultLW
		local hasMarks = globalShowSymbols or (s.marks and #s.marks > 0) or (sLW <= 0)
		local sShape = shapeList[(i - 1) % #shapeList + 1] or "circle"
		s._lw = sLW
		s._hasMarks = hasMarks
		s._shape = sShape

		if showLegend then
			local rawLegTitle = args["y" .. i .. "Title"] or args[i .. " name"] or s.title or ("Series " .. i)
			local strLegTitle = tostring(rawLegTitle)
			local lines = (s.parametric and not s.kind)
				and mw.text.split(strLegTitle, "%s*;%s*")
				or { strLegTitle }

			local lineBoxes = {}
			local itemW, itemH = 0, 0
			for _, ln in ipairs(lines) do
				local b
				if useHtmlLabels and hasLocalizableText(ln) then
					b = {
						svg = "",
						html = ln,
						width = estimateHtmlTextWidth(ln, legFontSize),
						ascent = legFontSize * 0.78,
						descent = legFontSize * 0.25
					}
				elseif useMathLegend then
					b = TextRenderer.renderMathSVG(ln, { fontSize = legFontSize })
				else
					b = {
						svg = format('<text x="0" y="0" font-family="sans-serif" font-size="%.1fpx" fill="#2b2b2b">%s</text>', legFontSize, esc(ln)),
						width = estimateHtmlTextWidth(ln, legFontSize),
						ascent = legFontSize * 0.75,
						descent = 3
					}
				end
				lineBoxes[#lineBoxes + 1] = b
				if b.width > itemW then itemW = b.width end
				itemH = itemH + max(15, b.ascent + b.descent + 3)
			end
			if itemW > maxItemW then maxItemW = itemW end
			local rowH = max(18, itemH + 3)
			totalItemsH = totalItemsH + rowH
				renderedItems[i] = {
				lines = lineBoxes,
				rowH = rowH,
				color = palette[(i - 1) % #palette + 1],
				isPhaseColor = (s.is_complex3d == true),
				lw = sLW,
				hasMarks = hasMarks,
				shape = sShape
			}
		end
	end

	local legBoxW = max(isTemplateCall and 82 or 92, maxItemW + 30)
	local legBoxH = 24 + totalItemsH + 6
	return renderedItems, legBoxW, legBoxH, legFontSize
end

-- 共用輔助:輸出圖例框與圖例文字 (SVG + HTML 絕對像素定位)
local function appendLegendOutput(buf, overlays, renderedItems, legX, legY, legBoxW, legBoxH, args, useHtmlLabels, legFontSize, defaultSymRadius, defaultSymStrokeW, customState)
	buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="fg-lbox"/>', legX, legY, legBoxW, legBoxH)

	local legendHeader = "圖例"
	if type(args.legend) == "string" and yesno(args.legend) == nil and args.legend ~= "函數" then
		legendHeader = args.legend
	end
	if useHtmlLabels then
		overlays[#overlays + 1] = format(
			'<span class="fg-ov fg-v fg-lhdr" style="left:%.1fpx;top:%.1fpx;">%s</span>',
			legX + 8, legY + 12, tostring(legendHeader)
		)
	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 + 16, esc(legendHeader))
	end

	local curY = legY + 24
	for _, item in ipairs(renderedItems) do
		local strokeColor, strokeOpacity = parseColor(item.color)
		local firstLine = item.lines[1]
		local midY = curY + firstLine.ascent - 2.3

		if item.lw > 0 then
			local swatchFill = item.isPhaseColor and "url(#fg-phase-grad)" or strokeColor
			buf[#buf + 1] = format(
				'<rect x="%.1f" y="%.1f" width="14" height="3.2" fill="%s"%s/>',
				legX + 8, midY - 1.6, swatchFill, (not item.isPhaseColor and strokeOpacity) and format(' fill-opacity="%.2f"', strokeOpacity) or ''
			)
		end
		if item.hasMarks then
			buf[#buf + 1] = renderSymbol(item.shape, legX + 15, midY, min(4.2, defaultSymRadius), strokeColor, strokeColor, defaultSymStrokeW, strokeOpacity, customState)
		end

		local lineY = curY
		for _, lb in ipairs(item.lines) do
			local baseLineY = lineY + lb.ascent + 1
			if lb.html then
				local centerLineY = lineY + lb.ascent - 2.3
				overlays[#overlays + 1] = format(
					'<span class="fg-ov fg-v" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>',
					legX + 26, centerLineY, legFontSize, tostring(lb.html)
				)
			else
				buf[#buf + 1] = format('<g transform="translate(%.2f, %.2f)">%s</g>', legX + 26, baseLineY, lb.svg)
			end
			lineY = lineY + max(15, lb.ascent + lb.descent + 3)
		end
		curY = curY + item.rowH
	end
end

-- 2D / 極座標主渲染器 (若傳入 3D 資料則自動轉交 p.render3DGraph)
function p.renderFunctionGraph(data, args)
	args = args or {}
	if data and data.is_3d then
		return p.render3DGraph(data, args)
	end

	local isTemplateCall = (args.is_template_call == true)
	local inputW = tonumber(args.width) or (isTemplateCall and 200 or 400)
	local inputH = tonumber(args.height) or (isTemplateCall and 100 or 300)
	local interpolate = string.lower(mw.text.trim(tostring(args.interpolate or "monotone")))
	local rawLW = args.lineWidth or args.linewidth
	local defaultLW = (rawLW ~= nil) and (tonumber(string.match(tostring(rawLW), "[%d%.]+")) or 0) or 2.2
	local animateMode = string.lower(mw.text.trim(tostring(args.animate or "no")))

	local series_list = data.series or {}
	if #series_list == 0 and data.x then
		local idx = 1
		while data["y" .. idx] or (idx == 1 and data.y) do
			series_list[#series_list + 1] = {
				x = mw.text.split(data["x" .. idx] or data.x, ","),
				y = mw.text.split(data["y" .. idx] or data.y, ","),
				title = data["y" .. idx .. "Title"] or ("Series " .. idx)
			}
			idx = idx + 1
		end
	end

	local shapeList = parseShapeArray(args.symbolsShape or args.symbolShape or args.shapes or args.symbols) or { "circle" }
	local defaultSymRadius = tonumber(args.symbolsSize or args.symbolSize) or max(3.2, defaultLW * 1.35)
	local defaultSymStrokeW = tonumber(args.symbolsStroke or args.symbolStroke) or 0
	local globalShowSymbols = (yesno(args.showSymbols or 'no') == true)

	local isPolarCoord = (data.all_polar == true)
	if not isPolarCoord and #series_list > 0 then
		local checkAll = true
		for _, s in ipairs(series_list) do
			if not s.polar then checkAll = false; break end
		end
		isPolarCoord = checkAll
	end

	local polarShape = string.lower(mw.text.trim(tostring(args.polar_shape or args.polarShape or "rect")))
	local isPolarCircle = isPolarCoord and (polarShape == "circle" or polarShape == "round")
	local isPolarRect = isPolarCoord and (not isPolarCircle)

	local equalScale = isPolarCoord or (yesno(args.equal_scale or args.equalScale or 'no') == true)
	local userStrictArg = args.strict_ticks or args.strictTicks
	local strictTicks = isPolarRect or (yesno(userStrictArg or 'no') == true)
	local xTickStep, yTickStep = tonumber(args.xTickStep), tonumber(args.yTickStep)
	local xUnit = tonumber(args.xUnit) or 1.0
	local xUnitLabel = mw.text.trim(tostring(args.xUnitLabel or ""))
	local yUnit = tonumber(args.yUnit) or 1.0
	local yUnitLabel = mw.text.trim(tostring(args.yUnitLabel or ""))
	local hasXUnit = (xUnitLabel ~= "") or (abs(xUnit - 1.0) > 1e-9)
	local hasYUnit = (yUnitLabel ~= "") or (abs(yUnit - 1.0) > 1e-9)

	local tickFractionX, tickFractionY
	if args.tickFraction ~= nil and args.tickFraction ~= "" and string.lower(tostring(args.tickFraction)) ~= "auto" then
		local tf = (yesno(args.tickFraction) == true)
		tickFractionX, tickFractionY = tf, tf
	else
		tickFractionX, tickFractionY = hasXUnit, hasYUnit
	end

	local palette = DEFAULT_COLORS
	if args.colors and mw.text.trim(args.colors) ~= "" then
		palette = mw.text.split(args.colors, "%s*,%s*")
	end

	local graphTitle = (not isTemplateCall) and args.title or nil
	local xLabel = args.xAxisTitle or args.x_label or (isPolarCircle and nil or "x")
	local yLabel = args.yAxisTitle or args.y_label or (isPolarCircle and nil or "y")
	if args.xAxisTitle == "" or args.x_label == "" then xLabel = nil end
	if args.yAxisTitle == "" or args.y_label == "" then yLabel = nil end

	local showLegend = true
	if args.legend ~= nil then
		showLegend = (yesno(args.legend) ~= false)
	elseif #series_list == 0 then
		showLegend = false
	end

	local useHtmlLabels = (args.html_labels == nil and args.htmlLabels == nil) and true or (yesno(args.html_labels or args.htmlLabels) ~= false)
	local useMathLegend = (args.mathLegend == nil) and true or (yesno(args.mathLegend) ~= false)

	local renderedItems, legBoxW, legBoxH, legFontSize = prepareLegendItems(
		series_list, args, palette, isTemplateCall, defaultLW, shapeList, globalShowSymbols, useHtmlLabels, useMathLegend, showLegend
	)

	local defaultLegPos = isTemplateCall and "right" or "top-right"
	local legendPos = string.lower(tostring(args.legendPosition or defaultLegPos))
	local isOutsideLegend = showLegend and (legendPos == "right" or legendPos == "outside")

	local pad, plotW, plotH, width, height
	if isTemplateCall then
		if isPolarCircle then
			local side = max(80, inputW)
			pad = { t = 20, b = xLabel and 34 or 20, l = yLabel and 42 or 28, r = isOutsideLegend and (legBoxW + 14) or 28 }
			plotW, plotH = side, side
		else
			pad = { t = 10, b = xLabel and 34 or 20, l = yLabel and 42 or 32, r = isOutsideLegend and (legBoxW + 8) or 10 }
			plotW, plotH = max(60, inputW), max(50, inputH)
		end
		width = pad.l + plotW + pad.r
		height = max(pad.t + plotH + pad.b, isOutsideLegend and (pad.t + legBoxH + 8) or 0)
	else
		pad = {
			t = graphTitle and 38 or (isPolarCircle and 22 or 16),
			b = xLabel and 44 or (isPolarCircle and 22 or 26),
			l = yLabel and 54 or (isPolarCircle and 28 or 42),
			r = isOutsideLegend and (legBoxW + 18) or (isPolarCircle and 28 or 20)
		}
		if isPolarCircle then
			local side = min(max(60, inputW - pad.l - pad.r), max(60, inputH - pad.t - pad.b))
			plotW, plotH = side, side
			width = pad.l + plotW + pad.r
			height = max(pad.t + plotH + pad.b, isOutsideLegend and (pad.t + legBoxH + 8) or 0)
		else
			width, height = inputW, inputH
			plotW, plotH = max(50, width - pad.l - pad.r), max(50, height - pad.t - pad.b)
		end
	end

	-- 掃描實際數據值域 (含離散打點 marks)
	local boundYMin, boundYMax = tonumber(args.y_min), tonumber(args.y_max)
	local dataXMin, dataXMax = math.huge, -math.huge
	local dataYMin, dataYMax = math.huge, -math.huge
	local maxPolarR = 0
	local allAutoDomainX = (#series_list > 0)

	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
			local vy_c = vy
			if boundYMin and vy_c < boundYMin then vy_c = boundYMin end
			if boundYMax and vy_c > boundYMax then vy_c = boundYMax end
			if vy_c < dataYMin then dataYMin = vy_c end
			if vy_c > dataYMax then dataYMax = vy_c end
			local r_dist = sqrt(vx * vx + vy * vy)
			if r_dist > maxPolarR then maxPolarR = r_dist end
		end
	end

	local impTouchMin, impTouchMax = nil, nil
	local hasIntMode = false
	local allYInteger = (#series_list > 0)
	local hasAnyValidY = false
	for _, s in ipairs(series_list) do
		if s.integer_mode then hasIntMode = true end
		if (not s.parametric or s.implicit) and not s.polar then allAutoDomainX = false end
		local n_pts = max(#(s.x or {}), #(s.raw_x or {}))
		for j = 1, n_pts do
			local vx, vy = getPointXY(s, j)
			updateBounds(vx, vy)
			if vy and vy == vy and abs(vy) < 1e12 then
				hasAnyValidY = true
				if abs(vy - floor(vy + 0.5)) > 1e-6 then
					allYInteger = false
				end
			end
		end
		for _, mk in ipairs(s.marks or {}) do
			updateBounds(mk.x, mk.y)
			if mk.y and abs(mk.y - floor(mk.y + 0.5)) > 1e-6 then
				allYInteger = false
			end
		end
		if s.implicit and s.imp_y_min and s.imp_y_max then
			local tol = (s.imp_y_max - s.imp_y_min) * 0.02
			if dataYMin <= s.imp_y_min + tol then impTouchMin = s.imp_y_min end
			if dataYMax >= s.imp_y_max - tol then impTouchMax = s.imp_y_max end
		end
	end
	if not hasAnyValidY then allYInteger = false end

	local xMin, xMax = tonumber(args.xAxisMin), tonumber(args.xAxisMax)
	local yMin, yMax = tonumber(args.yAxisMin), tonumber(args.yAxisMax)

	if isPolarCoord then
		if maxPolarR <= 0 then maxPolarR = 1 end
		local baseRTicks = buildAxisTicks(0, maxPolarR, xTickStep or yTickStep, xUnit, hasXUnit, true, 4)
		local topR = (#baseRTicks > 0 and baseRTicks[#baseRTicks] >= maxPolarR * 0.95) and baseRTicks[#baseRTicks] or maxPolarR
		local rBound = topR * (isPolarRect and 1.15 or 1.05)
		xMin, xMax = xMin or -rBound, xMax or rBound
		yMin, yMax = yMin or -rBound, yMax or rBound
	else
		if not xMin or not xMax then
			if allAutoDomainX and dataXMin < math.huge then
				local xSpanData = (dataXMax > dataXMin) and (dataXMax - dataXMin) or 1
				xMin = xMin or (dataXMin - xSpanData * 0.06)
				xMax = xMax or (dataXMax + xSpanData * 0.06)
			else
				xMin = xMin or tonumber(args.x_start) or (dataXMin < math.huge and dataXMin or 0)
				xMax = xMax or tonumber(args.x_end) or (dataXMax > -math.huge and dataXMax or 1)
			end
		end
		if xMin == xMax then xMax = xMin + 1 end

		if not yMin or not yMax then
			if dataYMin == math.huge then dataYMin, dataYMax = -1, 1 end
			local dataYSpan = dataYMax - dataYMin
			local padY = (dataYSpan == 0) and 1 or (dataYSpan * 0.08)

			if boundYMin and boundYMax then
				local boundSpan = boundYMax - boundYMin
				if dataYSpan > 0 and boundSpan > dataYSpan * 3.0 then
					yMin = yMin or max(boundYMin, dataYMin - padY)
					yMax = yMax or min(boundYMax, dataYMax + padY)
				else
					yMin, yMax = yMin or boundYMin, yMax or boundYMax
				end
			else
				yMin = yMin or boundYMin or impTouchMin or (dataYMin - padY)
				yMax = yMax or boundYMax or impTouchMax or (dataYMax + padY)
			end
		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 pad.l + ((x - xMin) / xSpan) * plotW end
	local function mapY(y) return pad.t + plotH - ((y - yMin) / ySpan) * plotH end

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

	local showGrid = (args.grid == nil) and true or (yesno(args.grid) ~= false)
	local showXGrid = (args.xGrid ~= nil) and yesno(args.xGrid) or showGrid
	local showYGrid = (args.yGrid ~= nil) and yesno(args.yGrid) or showGrid
	local tickFontSize = isTemplateCall and "10px" or "11px"
	local xFmt, yFmt = args.xAxisFormat, args.yAxisFormat

	-- 1. 繪製座標系統
	if isPolarCircle then
		local cx, cy = mapX(0), mapY(0)
		local maxRMath = maxPolarR > 0 and maxPolarR or (min(xSpan, ySpan) * 0.45)
		local rTicks = buildAxisTicks(0, maxRMath, xTickStep or yTickStep, xUnit, hasXUnit, true, 4)
		local outerRMath = (#rTicks > 0 and rTicks[#rTicks] >= maxRMath * 0.95) and rTicks[#rTicks] or maxPolarR
		local outerRPx = (outerRMath / xSpan) * plotW

		clipShapeTag = format('<circle cx="%.2f" cy="%.2f" r="%.2f"/>', cx, cy, outerRPx + 2.5)
		buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" class="fg-bg"/>', 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" class="fg-pgrd"/>', cx, cy, rPx)
				end
			end
		end

		local angleStep = (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 * math.pi / 180
			local cosA, sinA = math.cos(rad), math.sin(rad)
			local ex, ey = cx + outerRPx * cosA, cy - outerRPx * sinA
			local isMainAxis = (deg % 90 == 0)
			if showGrid or isMainAxis then
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="%s"/>', cx, cy, ex, ey, isMainAxis and "fg-ax" or "fg-grd")
			end
			local lblR = outerRPx + (isTemplateCall and 12 or 14)
			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="fg-ptk">%s</text>', lx, ly, anchor, angleLabels[deg] or (deg .. "°"))
		end

		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, tickFractionX, xFmt)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="fg-rtk">%s</text>', cx + rPx, cy + 11, esc(rLbl))
			end
		end
	else
		clipShapeTag = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>', pad.l, pad.t, plotW, plotH)
		buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="fg-bg"/>', pad.l, pad.t, plotW, plotH)

		local xDivs, yDivs = tonumber(args.xDivisions) or 8, tonumber(args.yDivisions) or 8
		local autoStrict = (userStrictArg == nil) or (yesno(userStrictArg) ~= false)
		local strictX = strictTicks or (autoStrict and hasIntMode)
		local strictY = strictTicks or (autoStrict and (hasIntMode or allYInteger))
		local xTicks, isCleanX = buildAxisTicks(xMin, xMax, xTickStep, xUnit, hasXUnit, strictX, xDivs, hasIntMode and not hasXUnit)
		local yTicks, isCleanY = buildAxisTicks(yMin, yMax, yTickStep, yUnit, hasYUnit, strictY, yDivs, allYInteger and not hasYUnit)

		local xStepLabel = (not isCleanX and plotW < 260) and 2 or 1
		local yStepLabel = (not isCleanY and plotH < 140) and 2 or 1
		if isCleanX and #xTicks > 9 and plotW < 260 then xStepLabel = 2 end
		if isCleanY and #yTicks > 6 and plotH < 140 then yStepLabel = 2 end

		for idx, xVal in ipairs(xTicks) do
			local px = mapX(xVal)
			if px >= pad.l - 0.5 and px <= pad.l + plotW + 0.5 then
				if (not isPolarRect) and showXGrid and px > pad.l + 1 and px < pad.l + plotW - 1 then
					buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="fg-grd"/>', px, pad.t, px, pad.t + plotH)
				end
				if (idx - 1) % xStepLabel == 0 or abs(xVal) < 1e-10 then
					local lbl = formatTickValue(xVal, xUnit, xUnitLabel, tickFractionX, isCleanX and nil or (xFmt or "%.2f"))
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="fg-tk">%s</text>', px, pad.t + plotH + 15, esc(lbl))
				end
			end
		end

		for idx, yVal in ipairs(yTicks) do
			local py = mapY(yVal)
			if py >= pad.t - 0.5 and py <= pad.t + plotH + 0.5 then
				if (not isPolarRect) and showYGrid and py > pad.t + 1 and py < pad.t + plotH - 1 then
					buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="fg-grd"/>', pad.l, py, pad.l + plotW, py)
				end
				if (idx - 1) % yStepLabel == 0 or abs(yVal) < 1e-10 then
					local lbl = formatTickValue(yVal, yUnit, yUnitLabel, tickFractionY, isCleanY and nil or (yFmt or "%.2f"))
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="fg-tk">%s</text>', pad.l - 6, py + 3.5, esc(lbl))
				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] = '<g clip-path="url(#fg-clip)">'
			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 "fg-pref" or "fg-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 * math.pi / 180
				local cosA, sinA = math.cos(rad), math.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="fg-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="fg-ax"/>', pad.l, cy, pad.l + plotW, cy) end
			if xMin <= 0 and xMax >= 0 then buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="fg-ax"/>', cx, pad.t, cx, pad.t + plotH) end

			for deg = 0, 359, angleStep do
				local rad = deg * math.pi / 180
				local cosA, sinA = math.cos(rad), math.sin(rad)
				local lblR = refRPx + 11
				local lx = max(pad.l + 14, min(pad.l + plotW - 14, cx + lblR * cosA))
				local ly = max(pad.t + 11, min(pad.t + 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="fg-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 < pad.l + plotW - 18 then
					local rLbl = formatTickValue(rk, xUnit, xUnitLabel, tickFractionX, xFmt)
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="fg-rtk">%s</text>', cx + rPx, cy + 11, esc(rLbl))
				end
				rk = rk + rStep
			end
			buf[#buf + 1] = '</g>'
		else
			if yMin <= 0 and yMax >= 0 then
				local py0 = mapY(0)
				buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="fg-ax"/>', pad.l, py0, pad.l + plotW, py0)
			end
			if xMin <= 0 and xMax >= 0 then
				local px0 = mapX(0)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="fg-ax"/>', px0, pad.t, px0, pad.t + plotH)
			end
		end
	end

	-- 2. 繪製函數曲線、跳躍不連續點與離散打點 (含 100/50 閾值智慧 Tooltip 節流機制)
	buf[#buf + 1] = '<g clip-path="url(#fg-clip)">'
	local yMargin = ySpan * 3.5
	local userJumpTh = tonumber(args.jumpThreshold or args.jump_threshold)
	local jumpCutSpan = userJumpTh or (ySpan * 1.5)
	local jumpType = string.lower(mw.text.trim(tostring(args.jumpType or args.jump_type or "auto")))
	local mjArg = args.markJumps or args.mark_jumps
	local showJumpCircles = (mjArg == nil) and true or (string.lower(tostring(mjArg)) == "auto" or yesno(mjArg) ~= false)
	local lineClass = (animateMode == "hover" or animateMode == "draw" or animateMode == "auto") and "fg-line fg-anim-path" or "fg-line"

	local enableTooltips = (args.tooltips == nil and args.showTooltips == nil) and true or (yesno(args.tooltips or args.showTooltips) ~= false)
	local totalMarkPts = 0
	local continuousSeriesCount = 0
	for _, s in ipairs(series_list) do
		local pts = {}
		local marks = s.marks or {}
		local isDiscreteMarked = (#marks > 0) or globalShowSymbols or (s._lw <= 0)
		s._isDiscreteMarked = isDiscreteMarked
		if isDiscreteMarked then
			if #marks > 0 then
				for _, mk in ipairs(marks) do
					if mk.x >= xMin - 1e-7 and mk.x <= xMax + 1e-7 and mk.y >= yMin - yMargin and mk.y <= yMax + yMargin then
						pts[#pts + 1] = { mk.x, mk.y }
					end
				end
			else
				local n_pts = max(#(s.x or {}), #(s.raw_x or {}))
				for j = 1, n_pts do
					local vx, vy = getPointXY(s, j)
					if vx and vy and vx == vx and vy == vy and vx >= xMin - 1e-7 and vx <= xMax + 1e-7 and vy >= yMin - yMargin and vy <= yMax + yMargin then
						pts[#pts + 1] = { vx, vy }
					end
				end
			end
			totalMarkPts = totalMarkPts + #pts
		else
			continuousSeriesCount = continuousSeriesCount + 1
		end
		s._activeMarks = pts
	end
	local enableMarkTooltips = enableTooltips and (totalMarkPts <= 100)
	local markTTBudget = enableMarkTooltips and min(50, totalMarkPts) or 0
	local continuousTTQuota = (enableTooltips and continuousSeriesCount > 0)
		and max(15, floor(max(20, 100 - markTTBudget) / continuousSeriesCount))
		or 0
	local globalPtIdx = 0
	local ttOverlays = {}

	for i, s in ipairs(series_list) do
		local rawColor = palette[(i - 1) % #palette + 1]
		local strokeColor, strokeOpacity = parseColor(rawColor)
		local opacityAttr = strokeOpacity and format(' stroke-opacity="%.2f"', strokeOpacity) or ''
		local sLW = s._lw
		local sShape = s._shape
		local sInterp = (s.parametric or s.polar or s.has_impulse) and "linear" or (s.default_interp or interpolate)

		local jumps = s.jumps or {}
		local jumpIdx, nJumps = 1, #jumps

		if sLW > 0 then
			local n_pts = max(#(s.x or {}), #(s.raw_x or {}))
			local segments, curr_seg = {}, {}
			local prev_vx, prev_vy = nil, nil

			for j = 1, n_pts do
				local vx, vy = getPointXY(s, j)
				local isValid = vx and vy and (vx == vx) and (vy == vy)
					and abs(vx) < 1e12 and abs(vy) < 1e12
					and vy >= (yMin - yMargin) and vy <= (yMax + yMargin)

				if isValid then
					local handledJump = false
					while jumpIdx <= nJumps and jumps[jumpIdx].x < vx - 1e-7 do
						local jmp = jumps[jumpIdx]
						if prev_vx and jmp.x > prev_vx + 1e-7 then
							curr_seg[#curr_seg + 1] = { 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) } }
							prev_vx, prev_vy = jmp.x, jmp.yR
						end
						jumpIdx = jumpIdx + 1
					end

					if jumpIdx <= nJumps and abs(vx - jumps[jumpIdx].x) <= 1e-6 then
						local jmp = jumps[jumpIdx]
						if #curr_seg > 0 then
							curr_seg[#curr_seg + 1] = { mapX(jmp.x), mapY(jmp.yL) }
							if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
						end
						curr_seg = { { mapX(jmp.x), mapY(jmp.yR) } }
						prev_vx, prev_vy = jmp.x, jmp.yR
						jumpIdx = jumpIdx + 1
						handledJump = true
					end

					if not handledJump then
						local isAutoCut = (not s.polar and not s.parametric 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_vx, prev_vy = vx, vy
					end
				else
					if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
					curr_seg = {}
					prev_vx, prev_vy = nil, 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/>',
					path_d, lineClass, strokeColor, sLW, opacityAttr
				)
			end
		end

		if showJumpCircles and #jumps > 0 and (mjArg ~= nil or args.jumps ~= nil or #jumps <= 25) then
			local jRadius = max(3.2, (sLW > 0 and sLW or 2.0) * 1.45)
			local jStrokeW = max(1.6, (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 == "right" or jumpType == "right-continuous" then
					hollowY1, solidY = pyL, pyR
				elseif jumpType == "left" or jumpType == "left-continuous" then
					solidY, hollowY1 = pyL, pyR
				else
					if jmp.y0 ~= nil then
						if abs(jmp.y0 - jmp.yR) <= 1e-4 then hollowY1, solidY = pyL, pyR
						elseif abs(jmp.y0 - jmp.yL) <= 1e-4 then solidY, hollowY1 = pyL, pyR
						else hollowY1, hollowY2, solidY = pyL, pyR, mapY(jmp.y0) end
					else
						hollowY1, hollowY2 = pyL, pyR
					end
				end

				if hollowY1 then buf[#buf + 1] = renderSymbol("circle", px, hollowY1, jRadius, "#f8f8f8", strokeColor, jStrokeW, strokeOpacity, customState) end
				if hollowY2 then buf[#buf + 1] = renderSymbol("circle", px, hollowY2, jRadius, "#f8f8f8", strokeColor, jStrokeW, strokeOpacity, customState) end
				if solidY then buf[#buf + 1] = renderSymbol("circle", px, solidY, jRadius, strokeColor, strokeColor, 1.0, strokeOpacity, customState) end
			end
		end

		local smvRaw = args["showMarkValues" .. i] or args.showMarkValues or args.show_mark_values or args.showValues
		local showMarkVals = (smvRaw ~= nil) and (yesno(smvRaw) == true) or false
		local mvFmt = args["markValueFormat" .. i] or args.markValueFormat or args.mark_value_format
		local mvDir = string.lower(mw.text.trim(tostring(args["markValueDir" .. i] or args.markValueDir or args.mark_value_dir or "top")))
		local sTitle = args["y" .. i .. "Title"] or args[i .. " name"] or s.title or ("y" .. i)
		-- 針對每個函數系列只展開一次 <math> 並記住 Strip Marker 字串,後續該系列所有 Tooltip 直接複用該 Strip Marker
		local ttTitleHtml = enableTooltips and TextRenderer.formatTooltipTitle(sTitle) or ""

		local function appendPointTooltip(vx, vy, px, py, isSolidDot)
			if px >= pad.l - 2 and px <= pad.l + plotW + 2 and py >= pad.t - 2 and py <= pad.t + plotH + 2 then
				local xStr = formatTickValue(vx, xUnit, xUnitLabel, tickFractionX, xFmt)
				local valStr = mvFmt and safeFormat(mvFmt, vy) or formatTickValue(vy, yUnit, yUnitLabel, tickFractionY, yFmt)
				local ttCls = "fg-tt"
				if py - pad.t < 28 then ttCls = ttCls .. " fg-tt-bot" end
				if px - pad.l < 48 then ttCls = ttCls .. " fg-tt-l"
				elseif (pad.l + plotW) - px < 48 then ttCls = ttCls .. " fg-tt-r" end
				local icoStyle = isSolidDot
					and format("border-color:%s;background-color:%s;", strokeColor, strokeColor)
					or format("border-color:%s;", strokeColor)
				local coordText = (s.implicit or (s.parametric and not s.polar))
					and format("(%s, %s)", esc(xStr), esc(valStr))
					or format("(%s): %s", esc(xStr), esc(valStr))
				ttOverlays[#ttOverlays + 1] = format(
					'<span class="fg-hb" style="left:%.1fpx;top:%.1fpx;"><span class="fg-hb-ring" style="border-color:%s;"></span><span class="%s"><span class="fg-tt-ico" style="%s"></span>%s %s</span></span>',
					px, py, strokeColor, ttCls, icoStyle, ttTitleHtml, coordText
				)
			end
		end

		local function renderPointAndLabel(vx, vy, hasTT)
			local px, py = mapX(vx), mapY(vy)
			buf[#buf + 1] = renderSymbol(sShape, px, py, defaultSymRadius, strokeColor, strokeColor, defaultSymStrokeW, strokeOpacity, customState)
			if py >= pad.t - 2 and py <= pad.t + plotH + 2 then
				local valStr = mvFmt and safeFormat(mvFmt, vy) or formatTickValue(vy, yUnit, yUnitLabel, tickFractionY, yFmt)
				if showMarkVals then
					local dir = mvDir
					if dir == "top" and (py - pad.t < 15) then dir = "bottom"
					elseif dir == "bottom" and (pad.t + plotH - py < 15) then dir = "top" end

					local tx, ty, anchor = px, py, "middle"
					local offset = defaultSymRadius + 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 - pad.l < 14 then anchor = "start"; tx = max(pad.l + 2, tx - 4)
						elseif pad.l + plotW - tx < 14 then anchor = "end"; tx = min(pad.l + plotW - 2, tx + 4) end
					end
					buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="fg-val">%s</text>', tx, ty, anchor, esc(valStr))
				end
				if hasTT then
					appendPointTooltip(vx, vy, px, py, false)
				end
			end
		end

		if s._isDiscreteMarked then
			for _, pt in ipairs(s._activeMarks or {}) do
				globalPtIdx = globalPtIdx + 1
				local hasTT = false
				if enableMarkTooltips then
					if totalMarkPts <= 50 then
						hasTT = true
					else
						hasTT = (floor((globalPtIdx - 1) * 50 / totalMarkPts) < floor(globalPtIdx * 50 / totalMarkPts))
					end
				end
				renderPointAndLabel(pt[1], pt[2], hasTT)
			end
		elseif enableTooltips and continuousTTQuota > 0 then
			if s.integer_mode then
				-- 整數取樣模式:Tooltip 嚴格僅配置於整數座標點上
				local intPts = {}
				local n_pts = max(#(s.x or {}), #(s.raw_x or {}))
				for j = 1, n_pts do
					local vx, vy = getPointXY(s, j)
					if vx and vy and vx >= xMin - 1e-7 and vx <= xMax + 1e-7 and vy >= yMin - 1e-7 and vy <= yMax + 1e-7 then
						intPts[#intPts + 1] = { vx, vy }
					end
				end
				local nInt = #intPts
				for idxPt, pt in ipairs(intPts) do
					if nInt <= continuousTTQuota or (floor((idxPt - 1) * continuousTTQuota / nInt) < floor(idxPt * continuousTTQuota / nInt)) then
						appendPointTooltip(pt[1], pt[2], mapX(pt[1]), mapY(pt[2]), true)
					end
				end
			else
				local curveSamples = RenderUtils.sampleCurveTooltipsByArcLength(
					s, mapX, mapY, xMin, xMax, yMin, yMax, jumpCutSpan, userJumpTh, continuousTTQuota
				)
				for _, sp in ipairs(curveSamples) do
					appendPointTooltip(sp.x, sp.y, sp.px, sp.py, true)
				end
			end
		end
	end
	buf[#buf + 1] = '</g>'

	if not isPolarCircle then
		buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="fg-bdr"/>', pad.l, pad.t, plotW, plotH)
	end

	-- 3. 標題與座標軸標籤 (使用外部 style.css 類別與精確 px 座標)
	local overlays = {}
	local finalW, finalH = floor(width + 0.5), floor(height + 0.5)

	if graphTitle then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="fg-ov fg-c fg-ttl" style="left:%.1fpx;top:17px;">%s</span>', width / 2, tostring(graphTitle))
		else
			buf[#buf + 1] = format('<text x="%.1f" y="23" text-anchor="middle" font-family="sans-serif" font-size="16px" font-weight="bold" fill="#2b2b2b">%s</text>', width / 2, esc(graphTitle))
		end
	end
	if xLabel then
		local xly = pad.t + plotH + (isTemplateCall and 30 or 36)
		local xCls = isTemplateCall and "fg-fs12" or "fg-fs14"
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="fg-ov fg-c %s" style="left:%.1fpx;top:%.1fpx;">%s</span>', xCls, pad.l + plotW / 2, xly - 4, tostring(xLabel))
		else
			buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="%s" fill="#2b2b2b">%s</text>', pad.l + plotW / 2, xly, isTemplateCall and "12px" or "14px", esc(xLabel))
		end
	end
	if yLabel then
		local yc = pad.t + plotH / 2
		local xc = isTemplateCall and 11 or 15
		local yCls = isTemplateCall and "fg-fs12" or "fg-fs14"
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="fg-ov fg-rot-y %s" style="left:%dpx;top:%.1fpx;">%s</span>', yCls, xc, yc, tostring(yLabel))
		else
			buf[#buf + 1] = format('<text x="%d" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="%s" fill="#2b2b2b" transform="rotate(-90, %d, %.1f)">%s</text>', xc, yc, isTemplateCall and "12px" or "14px", xc, yc, esc(yLabel))
		end
	end

	-- 4. 繪製圖例
	if showLegend and #renderedItems > 0 then
		local legX, legY
		if isOutsideLegend then
			legX = pad.l + plotW + (isPolarCircle and 14 or 6)
			legY = pad.t
		elseif legendPos == "top-left" then
			legX, legY = pad.l + 8, pad.t + 8
		elseif legendPos == "bottom-right" then
			legX, legY = pad.l + plotW - legBoxW - 8, pad.t + plotH - legBoxH - 8
		else
			legX, legY = pad.l + plotW - legBoxW - 8, pad.t + 8
		end
		appendLegendOutput(buf, overlays, renderedItems, legX, legY, legBoxW, legBoxH, args, useHtmlLabels, legFontSize, defaultSymRadius, defaultSymStrokeW, customState)
	end

	-- 5. 寫入內部 <style>、裁切遮罩與自訂符號 <defs>
	local internalStyle = buildInternalSvgStyle(tickFontSize, animateMode)
	buf[1] = format('<defs>%s<clipPath id="fg-clip">%s</clipPath>%s</defs>', internalStyle, clipShapeTag, table.concat(customState.defs))

	local 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', 'function-graph-svg')
		:setContent(table.concat(buf))
		:toImage()

	for k = 1, #ttOverlays do
		overlays[#overlays + 1] = ttOverlays[k]
	end
	return wrapHybridOutput(svgImg, finalW, finalH, overlays)
end

-- 3D 曲面與 3D 空間曲線渲染器
function p.render3DGraph(data3D, args)
	args = args or {}
	local isTemplateCall = (args.is_template_call == true)
	local inputW = tonumber(args.width) or (isTemplateCall and 200 or 420)
	local rawH = tonumber(args.height)
	-- 若在 {{函數圖形}} 中未指定高度 (模板預設傳入 100),自動提升 3D 繪圖區高度為 180 以避免立體盒子被壓扁
	local inputH = (isTemplateCall and (not rawH or rawH == 100)) and max(180, floor(inputW * 0.9)) or (rawH or 340)

	local rawLW = args.lineWidth or args.linewidth
	local defaultLW = (rawLW ~= nil) and (tonumber(string.match(tostring(rawLW), "[%d%.]+")) or 0) or 2.2
	local series_list = data3D.series or {}
	local palette = DEFAULT_COLORS
	if args.colors and mw.text.trim(args.colors) ~= "" then
		palette = mw.text.split(args.colors, "%s*,%s*")
	end

	local isComplex3D = (data3D.is_complex3d == true)
	local graphTitle = (not isTemplateCall) and args.title or nil
	local xLabel = args.xAxisTitle or args.x_label or (isComplex3D and "實軸 (Re)" or "x")
	local yLabel = args.yAxisTitle or args.y_label or (isComplex3D and "虛軸 (Im)" or "y")
	local zLabel = args.zAxisTitle or args.z_label or (isComplex3D and "|f(z)|" or "z")
	if args.xAxisTitle == "" or args.x_label == "" then xLabel = nil end

	if args.yAxisTitle == "" or args.y_label == "" then yLabel = nil end
	if args.zAxisTitle == "" or args.z_label == "" then zLabel = nil end

	local showLegend = true
	if args.legend ~= nil then
		showLegend = (yesno(args.legend) ~= false)
	elseif #series_list == 0 then
		showLegend = false
	end

	local useHtmlLabels = (args.html_labels == nil and args.htmlLabels == nil) and true or (yesno(args.html_labels or args.htmlLabels) ~= false)
	local useMathLegend = (args.mathLegend == nil) and true or (yesno(args.mathLegend) ~= false)
	local shapeList = { "circle" }
	local customState = { count = 0, map = {}, defs = {} }

	local renderedItems, legBoxW, legBoxH, legFontSize = prepareLegendItems(
		series_list, args, palette, isTemplateCall, defaultLW, shapeList, false, useHtmlLabels, useMathLegend, showLegend
	)

	local defaultLegPos = isTemplateCall and "right" or "top-right"
	local legendPos = string.lower(tostring(args.legendPosition or defaultLegPos))
	local isOutsideLegend = showLegend and (legendPos == "right" or legendPos == "outside")

	local pad, plotW, plotH, width, height
	if isTemplateCall then
		pad = { t = 14, b = 26, l = 36, r = isOutsideLegend and (legBoxW + 10) or 16 }
		plotW, plotH = max(120, inputW), max(120, inputH)
		width = pad.l + plotW + pad.r
		height = max(pad.t + plotH + pad.b, isOutsideLegend and (pad.t + legBoxH + 8) or 0)
	else
		width, height = inputW, inputH
		pad = { t = graphTitle and 36 or 18, b = 28, l = 42, r = isOutsideLegend and (legBoxW + 16) or 22 }
		plotW, plotH = max(120, width - pad.l - pad.r), max(120, height - pad.t - pad.b)
	end

	local cx = pad.l + plotW * 0.52
	local cy = pad.t + plotH * 0.50
	local plotRadius = min(plotW, plotH) * 0.37

	local equalScale = (yesno(args.equal_scale or args.equalScale or 'no') == true)
	local cam = MathEngine.buildCamera(data3D.bounds, { cx, cy }, plotRadius, {
		view_theta = args.view_theta or args.azimuth,
		view_phi = args.view_phi or args.elevation,
		view_roll = args.view_roll or args.roll,
		projection = args.projection,
		view_dist = args.view_dist,
		equal_scale = equalScale,
		box_aspect = args.box_aspect
	})
	local boxGeo = MathEngine.buildBoxGeometry(cam)

	local tickFontSize = isTemplateCall and "9.5px" or "10.5px"
	local phaseGradDef = isComplex3D
		and '<linearGradient id="fg-phase-grad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#ff0000"/><stop offset="17%" stop-color="#ffff00"/><stop offset="33%" stop-color="#00ff00"/><stop offset="50%" stop-color="#00ffff"/><stop offset="67%" stop-color="#0000ff"/><stop offset="83%" stop-color="#ff00ff"/><stop offset="100%" stop-color="#ff0000"/></linearGradient>'
		or ""
	local buf = { format("<defs>%s%s</defs>", buildInternalSvgStyle(tickFontSize, "no"), phaseGradDef) }
	local overlays = {}
	local finalW, finalH = floor(width + 0.5), floor(height + 0.5)

	local b = data3D.bounds
	local xTicks = getComplexTicks(b.xMin, b.xMax, args.xTickStep, 4)
	local yTicks = getComplexTicks(b.yMin, b.yMax, args.yTickStep, 4)
	local zTicks = getComplexTicks(b.zMin, b.zMax, args.zTickStep, 4)
	local showAxes = (args.axes == nil and args.showAxes == nil) and true or (yesno(args.axes or args.showAxes) ~= false)
	local showGrid = (args.grid == nil) and true or (yesno(args.grid) ~= false)

	local bx, by, bz = cam.boxScale[1], cam.boxScale[2], cam.boxScale[3]

	-- 1. 繪製 3D 立方體背景三面背板與 3D 格線
	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, b.yMin, b.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="fg-grd"/>', x1, y1, x2, y2)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="fg-grd"/>', x3, y3, x4, y4)
			end
			for _, yv in ipairs(yTicks) do
				local _, ny = cam.toNorm(b.xMin, yv, b.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="fg-grd"/>', x1, y1, x2, y2)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="fg-grd"/>', x3, y3, x4, y4)
			end
			for _, zv in ipairs(zTicks) do
				local _, _, nz = cam.toNorm(b.xMin, b.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="fg-grd"/>', x1, y1, x2, y2)
				buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="fg-grd"/>', x3, y3, x4, y4)
			end
		end
	end

	-- 2. 繪製經由畫家演算法排序後的 3D 曲面網格與空間曲線
	local showMesh = (args.mesh == nil) and true or (yesno(args.mesh) ~= false)
	local primitives = MathEngine.buildScenePrimitives(data3D, cam, palette, {
		colormap = args.colormap,
		colors = args.colors,
		mesh = showMesh,
		surfaceOpacity = args.surfaceOpacity or args.opacity,
		lineWidth = defaultLW
	})

	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
			buf[#buf + 1] = format(
				'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f" stroke-linecap="round"%s/>',
				prim.x1, prim.y1, prim.x2, prim.y2, prim.stroke, prim.strokeW, opAttr
			)
		end
	end

	-- 3. 繪製前景 3 條外輪廓主座標軸、刻度與軸標題
	if showAxes then
		local axX, axY, axZ = boxGeo.axes.x, boxGeo.axes.y, boxGeo.axes.z
		local tLen = 0.06

		-- X 軸主線與刻度
		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="fg-ax"/>', xx1, xy1, xx2, xy2)
		for _, xv in ipairs(xTicks) do
			local nx = cam.toNorm(xv, b.yMin, b.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="fg-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="fg-ov fg-c fg-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="fg-tk">%s</text>', lx, ly, esc(xLabel))
			end
		end

		-- Y 軸主線與刻度
		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="fg-ax"/>', yx1, yy1, yx2, yy2)
		for _, yv in ipairs(yTicks) do
			local _, ny = cam.toNorm(b.xMin, yv, b.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="fg-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="fg-ov fg-c fg-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="fg-tk">%s</text>', lx, ly, esc(yLabel))
			end
		end

		-- Z 軸主線與刻度
		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="fg-ax"/>', zx1, zy1, zx2, zy2)
		for _, zv in ipairs(zTicks) do
			local _, _, nz = cam.toNorm(b.xMin, b.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="fg-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="fg-ov fg-c fg-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', max(14, lx), max(12, ly), tostring(zLabel))
			else
				buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" class="fg-tk">%s</text>', max(14, lx), max(12, ly), esc(zLabel))
			end
		end
	end

	-- 4. 標題與圖例
	if graphTitle then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="fg-ov fg-c fg-ttl" style="left:%.1fpx;top:17px;">%s</span>', width / 2, tostring(graphTitle))
		else
			buf[#buf + 1] = format('<text x="%.1f" y="23" text-anchor="middle" font-family="sans-serif" font-size="16px" font-weight="bold" fill="#2b2b2b">%s</text>', width / 2, esc(graphTitle))
		end
	end

	if showLegend and #renderedItems > 0 then
		local legX = isOutsideLegend and (pad.l + plotW + 6) or (pad.l + plotW - legBoxW - 6)
		local legY = pad.t + 4
		appendLegendOutput(buf, overlays, renderedItems, legX, legY, legBoxW, legBoxH, args, useHtmlLabels, legFontSize, 3.5, 0, customState)
	end

	local enableTooltips = (args.tooltips == nil and args.showTooltips == nil) and true or (yesno(args.tooltips or args.showTooltips) ~= false)
	if enableTooltips then
		local tt3d = MathEngine.build3DTooltips(data3D, primitives, cam, palette, args, pad.l, pad.t, plotW, plotH)
		for k = 1, #tt3d do
			overlays[#overlays + 1] = tt3d[k]
		end
	end

	local 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', 'function-graph-svg')
		:setContent(table.concat(buf))
		:toImage()

	return wrapHybridOutput(svgImg, finalW, finalH, overlays)
end

-- 複變函數定義域著色圖 SVG 渲染器
function p.renderComplexGraph(complex_data, args)
	args = args or {}
	local plotSize = tonumber(args.width) or tonumber(complex_data.width) or 160

	local masterAxes = (args.axes == nil and args.showAxes == nil) and true or (yesno(args.axes or args.showAxes) ~= false)
	local showInnerAxes = masterAxes and ((args.innerAxes == nil and args.originAxes == nil) and true or (yesno(args.innerAxes or args.originAxes) ~= false))
	local showInnerTicks = masterAxes and (yesno(args.innerTicks or 'no') == true)
	local showInnerTickLabels = masterAxes and (yesno(args.innerTickLabels or 'no') == true)
	local showOuterTicks = masterAxes and ((args.outerTicks == nil and args.ticks == nil) and true or (yesno(args.outerTicks or args.ticks) ~= false))
	local showTickLabels = masterAxes and ((args.tickLabels == nil and args.showTickNumbers == nil) and true or (yesno(args.tickLabels or args.showTickNumbers) ~= false))
	local showAxisTitles = masterAxes and ((args.axisTitles == nil and args.showAxisTitles == nil) and true or (yesno(args.axisTitles or args.showAxisTitles) ~= false))

	local showGrid = (yesno(args.grid or 'no') == true)
	local showXGrid = (args.xGrid ~= nil) and (yesno(args.xGrid) == true) or showGrid
	local showYGrid = (args.yGrid ~= nil) and (yesno(args.yGrid) == true) or showGrid
	local gridStyle = string.lower(tostring(args.gridStyle or "dashed"))

	local xUnit = tonumber(args.xUnit) or 1.0
	local xUnitLabel = mw.text.trim(tostring(args.xUnitLabel or ""))
	local yUnit = tonumber(args.yUnit) or 1.0
	local yUnitLabel = mw.text.trim(tostring(args.yUnitLabel or ""))
	local hasXUnit = (xUnitLabel ~= "") or (abs(xUnit - 1.0) > 1e-9)
	local hasYUnit = (yUnitLabel ~= "") or (abs(yUnit - 1.0) > 1e-9)
	local useFracX = (args.tickFraction ~= nil and args.tickFraction ~= "auto") and (yesno(args.tickFraction) == true) or hasXUnit
	local useFracY = (args.tickFraction ~= nil and args.tickFraction ~= "auto") and (yesno(args.tickFraction) == true) or hasYUnit

	local xTitle = args.xAxisTitle or args.x_label or "實軸 (Re)"
	local yTitle = args.yAxisTitle or args.y_label or "虛軸 (Im)"
	if args.xAxisTitle == "" or args.x_label == "" then xTitle = nil end
	if args.yAxisTitle == "" or args.y_label == "" then yTitle = nil end
	local hasTitle = args.title and mw.text.trim(args.title) ~= ""
	local imagSuffix = tostring(args.imagSuffix or "")

	local padL, padB = 6, 6
	local padT = hasTitle and 26 or 8
	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 totalW = plotSize + padL + padR
	local totalH = plotSize + padT + padB

	local xMin = tonumber(complex_data.x_start) or -5
	local xMax = tonumber(complex_data.x_end) or 5
	local yMin = tonumber(complex_data.y_start) or -5
	local yMax = tonumber(complex_data.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 padL + ((x - xMin) / xSpan) * plotSize end
	local function mapY(y) return padT + plotSize - ((y - yMin) / ySpan) * plotSize end

	local pixels = complex_data.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)

	local buf = {
		format('<defs>%s<clipPath id="cg-clip"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath></defs>', buildInternalSvgStyle("10px", "no"), padL, padT, plotSize, plotSize),
		format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#7f7f7f"/>', padL, padT, plotSize, plotSize),
		'<g shape-rendering="crispEdges">'
	}

	for r = 1, nRows do
		local row = pixels[r]
		local py = padT + (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
			local px = padL + (c - 1) * cellW
			buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>', px, 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, args.xTickStep or args.tickStep, targetTicks)
	local yTicks = getComplexTicks(yMin, yMax, args.yTickStep or args.tickStep, targetTicks)
	local xFmt, yFmt = args.xAxisFormat or "%g", args.yAxisFormat or "%g"

	buf[#buf + 1] = '<g clip-path="url(#cg-clip)">'
	local dashAttr = (gridStyle == "solid") and "" or ' stroke-dasharray="3,2"'

	if showXGrid 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, padT, px, padT + plotSize, dashAttr)
			end
		end
	end
	if showYGrid then
		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/>', padL, py, padL + 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"/>', padL, py0, padL + 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, padT, px0, padT + 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, xFmt)
						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, yFmt) .. (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" fill="none" stroke="#2b2b2b" stroke-width="1.1"/>', padL, padT, plotSize, plotSize)

	local tickLen = 4
	for _, tk in ipairs(xTicks) do
		local px = mapX(tk)
		if px >= padL - 0.5 and px <= padL + 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, padT + plotSize, px, padT + plotSize + tickLen) end
			if showTickLabels then
				local labelY = padT + plotSize + (showOuterTicks and (tickLen + 11) or 12)
				local lbl = formatTickValue(tk, xUnit, xUnitLabel, useFracX, xFmt)
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="fg-tk">%s</text>', px, labelY, esc(lbl))
			end
		end
	end

	for _, tk in ipairs(yTicks) do
		local py = mapY(tk)
		if py >= padT - 0.5 and py <= padT + 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"/>', padL - tickLen, py, padL, py) end
			if showTickLabels then
				local labelX = padL - (showOuterTicks and (tickLen + 3) or 4)
				local lbl = formatTickValue(tk, yUnit, yUnitLabel, useFracY, yFmt) .. (tk ~= 0 and imagSuffix or "")
				buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="fg-tk">%s</text>', labelX, py + 3.5, esc(lbl))
			end
		end
	end

	local useHtmlLabels = (args.html_labels == nil and args.htmlLabels == nil) and true or (yesno(args.html_labels or args.htmlLabels) ~= false)
	local overlays = {}
	local finalW, finalH = floor(totalW + 0.5), floor(totalH + 0.5)

	if hasTitle then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="fg-ov fg-c fg-cttl" style="left:%.1fpx;top:13px;">%s</span>', padL + plotSize / 2, tostring(args.title))
		else
			buf[#buf + 1] = format('<text x="%.1f" y="17" text-anchor="middle" font-family="sans-serif" font-size="13px" font-weight="bold" fill="#2b2b2b">%s</text>', padL + plotSize / 2, esc(args.title))
		end
	end
	if showAxisTitles and xTitle then
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="fg-ov fg-c fg-fs11" style="left:%.1fpx;top:%.1fpx;">%s</span>', padL + plotSize / 2, totalH - 9, 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>', padL + plotSize / 2, totalH - 5, esc(xTitle))
		end
	end
	if showAxisTitles and yTitle then
		local yc = padT + plotSize / 2
		if useHtmlLabels then
			overlays[#overlays + 1] = format('<span class="fg-ov fg-rot-y fg-fs11" style="left:11px;top:%.1fpx;">%s</span>', yc, tostring(yTitle))
		else
			buf[#buf + 1] = format('<text x="13" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="#2b2b2b" transform="rotate(-90, 13, %.1f)">%s</text>', yc, yc, esc(yTitle))
		end
	end

	local enableTooltips = (args.tooltips == nil and args.showTooltips == nil) and true or (yesno(args.tooltips or args.showTooltips) ~= false)
	if enableTooltips and complex_data.tooltips then
		local cTitleHtml = TextRenderer.formatTooltipTitle(tostring(args.expr or "f(z)"))
		local gridBoxSize = max(12, floor(plotSize / 10))
		for _, item in ipairs(complex_data.tooltips) do
			local px, py = mapX(item.x), mapY(item.y)
			local zStr = format("%s%s%si", formatTickValue(item.x), item.y >= 0 and "+" or "", formatTickValue(item.y))
			local wStr = format("%.2f%s%.2fi", item.re, item.im >= 0 and "+" or "", item.im)
			local ttCls = "fg-tt"
			if py - padT < 28 then ttCls = ttCls .. " fg-tt-bot" end
			if px - padL < 60 then ttCls = ttCls .. " fg-tt-l"
			elseif (padL + plotSize) - px < 60 then ttCls = ttCls .. " fg-tt-r" end
			overlays[#overlays + 1] = format(
				'<span class="fg-hb" style="left:%.1fpx;top:%.1fpx;width:%dpx;height:%dpx;"><span class="fg-hb-ring" style="border-color:#ffffff;"></span><span class="%s"><span class="fg-tt-ico" style="border-color:#333;background-color:%s;"></span>%s (%s): %s</span></span>',
				px, py, gridBoxSize, gridBoxSize, ttCls, item.color, cTitleHtml, esc(zStr), esc(wStr)
			)
		end
	end

	local 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', 'complex-graph-svg')
		:setContent(table.concat(buf))
		:toImage()

	return wrapHybridOutput(svgImg, finalW, finalH, overlays)
end

return p