模組: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