模組:PythonScript/ExtraLibrarys/Matplotlib/SVGRenderer
外观
-- Module:PythonScript/ExtraLibrarys/Matplotlib/SVGRenderer
-- 專責 Matplotlib 多子圖 (Subplots) SVG 座標軸與網格框架、圖例與標題排版、
-- 3D 空間投影與 <desc> JSON 互動資料封裝、複變定義域著色網格,以及 mw.svg 最終渲染輸出
local p = {}
local RendererUtils = require("Module:PythonScript/ExtraLibrarys/Matplotlib/RendererUtils")
local MathEngine = require("Module:PythonScript/ExtraLibrarys/Matplotlib/MathEngine")
local ChartRenderer = require("Module:PythonScript/ExtraLibrarys/Matplotlib/ChartRenderer")
local format, floor, abs, max, min, sqrt = string.format, math.floor, math.abs, math.max, math.min, math.sqrt
local sin, cos, pi = math.sin, math.cos, math.pi
local DEFAULT_COLORS = RendererUtils.DEFAULT_COLORS
local parseColor = RendererUtils.parseColor
local esc = RendererUtils.esc
local hasLocalizableText = RendererUtils.hasLocalizableText
local estimateHtmlTextWidth = RendererUtils.estimateHtmlTextWidth
local expandMathTags = RendererUtils.expandMathTags
local renderMathSVG = RendererUtils.renderMathSVG
local buildInternalSvgStyle = RendererUtils.buildInternalSvgStyle
local wrapHybridOutput = RendererUtils.wrapHybridOutput
local renderSymbol = RendererUtils.renderSymbol
local formatTickValue = RendererUtils.formatTickValue
local buildAxisTicks = RendererUtils.buildAxisTicks
local getComplexTicks = RendererUtils.getComplexTicks
local getDashArray = RendererUtils.getDashArray
local sampleColormap = RendererUtils.sampleColormap
-- 補充常用深色具名顏色
RendererUtils.NAMED_COLORS.darkblue = "#00008b"
RendererUtils.NAMED_COLORS.darkgreen = "#006400"
RendererUtils.NAMED_COLORS.darkmagenta = "#8b008b"
RendererUtils.NAMED_COLORS.darkcyan = "#008b8b"
RendererUtils.NAMED_COLORS.darkslateblue = "#483d8b"
-- ==========================================
-- 1. 圖例 (Legend) 與子圖標題 SVG/HTML 混合排版器
-- ==========================================
local function prepareAxesLegend(ax, palette, useHtmlLabels)
local items = {}
local maxItemW = 48
local totalItemsH = 0
local legFontSize = tonumber(ax.legend_fontsize) or 11.0
local useMathLegend = (ax.math_legend == true)
local function addLegendEntry(lbl, baseCol, alpha, lw, linestyle, hasMarker, marker, isPatch)
if not lbl or lbl == "" or string.match(tostring(lbl), "^_nolegend_") then return end
local strLbl = tostring(lbl)
local b
if useHtmlLabels then
b = {
svg = "",
html = expandMathTags(strLbl),
width = estimateHtmlTextWidth(strLbl, legFontSize),
ascent = legFontSize * 0.78,
descent = legFontSize * 0.24
}
elseif useMathLegend then
b = renderMathSVG(strLbl, { fontSize = legFontSize })
else
b = {
svg = format('<text x="0" y="0" font-family="sans-serif" font-size="%.1fpx" fill="#2b2b2b">%s</text>', legFontSize, esc(strLbl)),
width = estimateHtmlTextWidth(strLbl, legFontSize),
ascent = legFontSize * 0.75,
descent = 3
}
end
if b.width > maxItemW then maxItemW = b.width end
local rowH = max(17, b.ascent + b.descent + 4)
totalItemsH = totalItemsH + rowH
items[#items + 1] = {
box = b,
rowH = rowH,
color = baseCol,
alpha = alpha,
lw = lw,
linestyle = linestyle,
hasMarker = hasMarker,
marker = marker or "circle",
isPatch = isPatch
}
end
for i, s in ipairs(ax.series or {}) do
if s.kind == "pie" and s.labels then
local pieColors = s.colors or palette
for j, plbl in ipairs(s.labels) do
local c = pieColors[(j - 1) % #pieColors + 1]
addLegendEntry(plbl, c, tonumber(s.alpha), 0, "none", false, nil, true)
end
else
local baseCol = (type(s.color) == "table" and s.color[1]) or s.color or palette[(i - 1) % #palette + 1]
local lw = tonumber(s.linewidth) or ((s.kind == "scatter" or s.kind == "scatter3d" or s.linestyle == "none") and 0 or 2.0)
local hasMarker = (s.marker ~= nil and s.marker ~= "" and s.marker ~= "none") or (s.kind == "scatter" or s.kind == "scatter3d" or s.kind == "errorbar")
local isPatch = (s.kind == "bar" or s.kind == "barh" or s.kind == "broken_barh" or s.kind == "fill" or s.kind == "fill_between" or s.kind == "hist" or s.kind == "surface3d" or s.kind == "violinplot")
addLegendEntry(s.label, baseCol, tonumber(s.alpha), lw, s.linestyle, hasMarker, s.marker, isPatch)
end
end
if #items == 0 then return nil, 0, 0, legFontSize end
local hasHdr = (ax.legend_title and ax.legend_title ~= "")
if hasHdr then
local hdrW = estimateHtmlTextWidth(ax.legend_title, 11.0)
if hdrW > maxItemW then maxItemW = hdrW end
end
local legBoxW = max(78, maxItemW + 30)
local legBoxH = (hasHdr and 22 or 6) + totalItemsH + 5
return items, legBoxW, legBoxH, legFontSize
end
local function appendAxesLegend(buf, overlays, items, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
if not items or #items == 0 then return end
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-lbox"/>', legX, legY, legBoxW, legBoxH)
local curY = legY + 6
if ax.legend_title and ax.legend_title ~= "" then
if useHtmlLabels then
overlays[#overlays + 1] = format(
'<span class="mpl-ov mpl-v mpl-lhdr" style="left:%.1fpx;top:%.1fpx;">%s</span>',
legX + 8, legY + 12, tostring(ax.legend_title)
)
else
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-family="sans-serif" font-size="11px" font-weight="bold" fill="#2b2b2b">%s</text>', legX + 8, legY + 15, esc(ax.legend_title))
end
curY = legY + 22
end
for _, it in ipairs(items) do
local strokeColor, strokeOpacity = parseColor(it.color)
local op = it.alpha or strokeOpacity
local midY = curY + it.box.ascent - 2.2
if it.isPatch then
buf[#buf + 1] = format(
'<rect x="%.1f" y="%.1f" width="14" height="8" fill="%s" stroke="%s" stroke-width="0.8"%s/>',
legX + 8, midY - 4, strokeColor, strokeColor, op and format(' fill-opacity="%.2f"', op) or ''
)
else
if it.lw > 0 then
local dash = getDashArray(it.linestyle)
local dashAttr = dash and format(' stroke-dasharray="%s"', dash) or ''
buf[#buf + 1] = format(
'<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="%.1f"%s%s/>',
legX + 7, midY, legX + 23, midY, strokeColor, min(3.0, it.lw), dashAttr, op and format(' stroke-opacity="%.2f"', op) or ''
)
end
if it.hasMarker then
buf[#buf + 1] = renderSymbol(it.marker, legX + 15, midY, 3.5, strokeColor, strokeColor, 0.8, op, customState)
end
end
if it.box.html then
overlays[#overlays + 1] = format(
'<span class="mpl-ov mpl-v" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>',
legX + 27, midY, legFontSize, tostring(it.box.html)
)
else
buf[#buf + 1] = format('<g transform="translate(%.2f, %.2f)">%s</g>', legX + 27, curY + it.box.ascent + 1, it.box.svg)
end
curY = curY + it.rowH
end
end
local function appendSubplotTitle(ax, box, plotL, plotW, plotT, buf, overlays, useHtmlLabels)
if not ax.title or ax.title == "" then return end
local tSize = tonumber(ax.title_fontsize) or 13
local tCol = parseColor(ax.title_color, "#2b2b2b")
local yRel = tonumber(ax.title_y)
local ty = plotT - 13
if yRel and yRel > 1.0 then
ty = max(box.y + 10, plotT - 10 - (yRel - 1.0) * 85)
end
local tx = plotL + plotW * 0.5
if useHtmlLabels then
overlays[#overlays + 1] = format(
'<span class="mpl-ov mpl-c" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;font-weight:bold;color:%s;">%s</span>',
tx, ty, tSize, tCol, tostring(ax.title)
)
else
buf[#buf + 1] = format(
'<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="%.1fpx" font-weight="bold" fill="%s">%s</text>',
tx, ty + 4, tSize, tCol, esc(ax.title)
)
end
end
local svgHelpers = {
prepareAxesLegend = prepareAxesLegend,
appendAxesLegend = appendAxesLegend,
appendSubplotTitle = appendSubplotTitle
}
-- ==========================================
-- 2. 2D 直角座標與極座標 SVG 座標框架渲染器
-- ==========================================
local function render2DAxes(ax, box, clipId, buf, overlays, customState, useHtmlLabels, animateMode)
for _, s in ipairs(ax.series or {}) do
if s.kind == "pie" then
ChartRenderer.renderPieAxes(ax, box, buf, overlays, customState, useHtmlLabels, svgHelpers)
return nil
end
end
local palette = ax.palette or DEFAULT_COLORS
local series_list = ax.series or {}
local isPolarCoord = (ax.projection == "polar")
local hasExplicitPolarProj = isPolarCoord
if not isPolarCoord and #series_list > 0 then
local allP = true
for _, s in ipairs(series_list) do
if not s.polar then allP = false; break end
end
isPolarCoord = allP
end
local defaultPolarShape = hasExplicitPolarProj and "circle" or "rect"
local polarShape = string.lower(tostring(ax.polar_shape or defaultPolarShape))
local isPolarCircle = isPolarCoord and (polarShape == "circle" or polarShape == "round")
local isPolarRect = isPolarCoord and (not isPolarCircle)
local equalScale = isPolarCoord or (ax.equal_scale == true) or (ax.aspect == "equal")
local strictTicks = isPolarRect and true or ax.strict_ticks
local xTickStep, yTickStep = tonumber(ax.x_tick_step), tonumber(ax.y_tick_step)
local xUnit = tonumber(ax.x_unit) or 1.0
local xUnitLabel = mw.text.trim(tostring(ax.x_unit_label or ""))
local yUnit = tonumber(ax.y_unit) or 1.0
local yUnitLabel = mw.text.trim(tostring(ax.y_unit_label or ""))
local hasXUnit = (xUnitLabel ~= "") or (abs(xUnit - 1.0) > 1e-9)
local hasYUnit = (yUnitLabel ~= "") or (abs(yUnit - 1.0) > 1e-9)
local tickFracX = (ax.tick_fraction ~= nil and ax.tick_fraction ~= "auto") and (ax.tick_fraction == true) or hasXUnit
local tickFracY = (ax.tick_fraction ~= nil and ax.tick_fraction ~= "auto") and (ax.tick_fraction == true) or hasYUnit
local showLegend = (ax.show_legend == true)
local legItems, legBoxW, legBoxH, legFontSize = nil, 0, 0, 11.0
if showLegend then
legItems, legBoxW, legBoxH, legFontSize = prepareAxesLegend(ax, palette, useHtmlLabels)
end
local legLoc = string.lower(tostring(ax.legend_loc or "upper right"))
local isOutsideLegend = showLegend and legItems and (legLoc == "right" or legLoc == "outside")
local hasColorbar = (ax.colorbar == true)
local hasTitle = (ax.title and ax.title ~= "")
local xLabel = ax.xlabel
local yLabel = ax.ylabel
local titleExtraT = (hasTitle and tonumber(ax.title_y) and tonumber(ax.title_y) > 1.0) and 14 or 0
local padL = yLabel and 48 or (isPolarCircle and 38 or 36)
local padR = (isOutsideLegend and (legBoxW + 12) or (isPolarCircle and 38 or 14)) + (hasColorbar and 48 or 0)
local padT = (hasTitle and 30 or (isPolarCircle and 26 or 12)) + titleExtraT
local padB = xLabel and 36 or (isPolarCircle and 26 or 22)
local plotW = max(50, box.w - padL - padR)
local plotH = max(50, box.h - padT - padB)
local plotL = box.x + padL
local plotT = box.y + padT
if isPolarCircle then
local side = min(plotW, plotH)
plotL = plotL + (plotW - side) * 0.5
plotT = plotT + (plotH - side) * 0.5
plotW, plotH = side, side
end
-- 委派 ChartRenderer 掃描數據值域並預計算統計量
local dataXMin, dataXMax, dataYMin, dataYMax, maxPolarR = ChartRenderer.scan2DDataBounds(series_list, isPolarCoord)
local xMin = ax.xlim and tonumber(ax.xlim[1]) or nil
local xMax = ax.xlim and tonumber(ax.xlim[2]) or nil
local yMin = ax.ylim and tonumber(ax.ylim[1]) or nil
local yMax = ax.ylim and tonumber(ax.ylim[2]) or nil
if isPolarCoord then
local userRMax = (ax.ylim and tonumber(ax.ylim[2])) or nil
if userRMax and userRMax > 0 then maxPolarR = userRMax end
if maxPolarR <= 0 then maxPolarR = 1 end
local baseRTicks = buildAxisTicks(0, maxPolarR, yTickStep or xTickStep, xUnit, hasXUnit, true, 4)
local topR = userRMax or ((#baseRTicks > 0 and baseRTicks[#baseRTicks] >= maxPolarR * 0.95) and baseRTicks[#baseRTicks] or maxPolarR)
local rBound = topR * (isPolarRect and 1.15 or 1.04)
xMin, xMax = -rBound, rBound
yMin, yMax = -rBound, rBound
else
if dataXMin == math.huge then dataXMin, dataXMax = 0, 1 end
if dataYMin == math.huge then dataYMin, dataYMax = 0, 1 end
local dxSpan = (dataXMax > dataXMin) and (dataXMax - dataXMin) or 1
local dySpan = (dataYMax > dataYMin) and (dataYMax - dataYMin) or 1
xMin = xMin or (dataXMin - dxSpan * 0.05)
xMax = xMax or (dataXMax + dxSpan * 0.05)
yMin = yMin or (dataYMin - dySpan * 0.06)
yMax = yMax or (dataYMax + dySpan * 0.06)
if xMin == xMax then xMin, xMax = xMin - 1, xMax + 1 end
if yMin == yMax then yMin, yMax = yMin - 1, yMax + 1 end
end
if equalScale then
local xSpan0, ySpan0 = xMax - xMin, yMax - yMin
local scaleX, scaleY = plotW / xSpan0, plotH / ySpan0
if scaleX < scaleY then
local targetYSpan = plotH / scaleX
local yMid = (yMin + yMax) * 0.5
yMin, yMax = yMid - targetYSpan * 0.5, yMid + targetYSpan * 0.5
elseif scaleY < scaleX then
local targetXSpan = plotW / scaleY
local xMid = (xMin + xMax) * 0.5
xMin, xMax = xMid - targetXSpan * 0.5, xMid + targetXSpan * 0.5
end
end
local xSpan, ySpan = xMax - xMin, yMax - yMin
local function mapX(x) return plotL + ((x - xMin) / xSpan) * plotW end
local function mapY(y) return plotT + plotH - ((y - yMin) / ySpan) * plotH end
local showGrid = (ax.grid == nil) and isPolarCoord or (ax.grid == true)
local showXGrid = (ax.x_grid ~= nil) and (ax.x_grid == true) or showGrid
local showYGrid = (ax.y_grid ~= nil) and (ax.y_grid == true) or showGrid
local xFmt, yFmt = ax.x_format, ax.y_format
local clipTag
local hideXLabels = (ax.hide_xtick_labels == true)
local hideYLabels = (ax.hide_ytick_labels == true)
local polarOuterCircleSvg = nil
if isPolarCircle then
local cx, cy = mapX(0), mapY(0)
local userRMax = (ax.ylim and tonumber(ax.ylim[2])) or nil
local maxRMath = userRMax or (maxPolarR > 0 and maxPolarR or (min(xSpan, ySpan) * 0.45))
local rTicks = ax.yticks or buildAxisTicks(0, maxRMath, yTickStep or xTickStep, xUnit, hasXUnit, true, 4)
local outerRMath = userRMax or ((#rTicks > 0 and rTicks[#rTicks] >= maxRMath * 0.95) and rTicks[#rTicks] or maxPolarR)
local outerRPx = (outerRMath / xSpan) * plotW
clipTag = format('<clipPath id="%s"><circle cx="%.2f" cy="%.2f" r="%.2f"/></clipPath>', clipId, cx, cy, outerRPx + 2.5)
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="#ffffff" stroke="none"/>', cx, cy, outerRPx)
polarOuterCircleSvg = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#222222" stroke-width="1.2"/>', cx, cy, outerRPx)
if showGrid then
for _, rk in ipairs(rTicks) do
if rk > 1e-9 and rk < outerRMath - 1e-6 then
local rPx = (rk / xSpan) * plotW
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#b8b8b8" stroke-width="0.9"/>', cx, cy, rPx)
end
end
end
if ax.xticks and #ax.xticks > 0 then
local xLabels = ax.xticklabels or {}
local xtickFs = tonumber(ax.xtick_fontsize) or 11
for idx, radVal in ipairs(ax.xticks) do
local rad = tonumber(radVal) or 0
local cosA, sinA = cos(rad), sin(rad)
local ex, ey = cx + outerRPx * cosA, cy - outerRPx * sinA
if showGrid then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b8b8b8" stroke-width="0.9"/>', cx, cy, ex, ey)
end
if not hideXLabels then
local lbl = xLabels[idx]
if lbl == nil then
local deg = floor((rad * 180 / pi) % 360 + 0.5)
lbl = tostring(deg) .. "°"
else
lbl = tostring(lbl)
end
if lbl ~= "" then
local lblR = outerRPx + 15
local lx, ly = cx + lblR * cosA, cy - lblR * sinA
if useHtmlLabels and hasLocalizableText(lbl) then
local alignCls = "mpl-c"
if cosA > 0.35 then alignCls = "mpl-v"; lx = lx - 4
elseif cosA < -0.35 then alignCls = "mpl-r"; lx = lx + 4 end
overlays[#overlays + 1] = format(
'<span class="mpl-ov %s" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>',
alignCls, lx, ly, xtickFs, lbl
)
else
local anchor = "middle"
if cosA > 0.25 then anchor = "start"; lx = lx - 2
elseif cosA < -0.25 then anchor = "end"; lx = lx + 2 end
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-ptk">%s</text>', lx, ly + 3.5, anchor, esc(lbl))
end
end
end
end
else
local angleStep = (hideXLabels or outerRPx < 75) and 45 or 30
local angleLabels = (angleStep == 45) and {
[0] = "0", [45] = "π/4", [90] = "π/2", [135] = "3π/4",
[180] = "π", [225] = "5π/4", [270] = "3π/2", [315] = "7π/4"
} or {
[0] = "0", [30] = "π/6", [60] = "π/3", [90] = "π/2",
[120] = "2π/3", [150] = "5π/6", [180] = "π", [210] = "7π/6",
[240] = "4π/3", [270] = "3π/2", [300] = "5π/3", [330] = "11π/6"
}
for deg = 0, 359, angleStep do
local rad = deg * pi / 180
local cosA, sinA = cos(rad), sin(rad)
local ex, ey = cx + outerRPx * cosA, cy - outerRPx * sinA
if showGrid then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b8b8b8" stroke-width="0.9"/>', cx, cy, ex, ey)
end
if not hideXLabels then
local lblR = outerRPx + 13
local lx, ly = cx + lblR * cosA, cy - lblR * sinA + 3.5
local anchor = "middle"
if cosA > 0.25 then anchor = "start"; lx = lx - 2
elseif cosA < -0.25 then anchor = "end"; lx = lx + 2 end
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-ptk">%s</text>', lx, ly, anchor, angleLabels[deg] or (deg .. "°"))
end
end
end
if not hideYLabels then
for _, rk in ipairs(rTicks) do
if rk > 1e-9 and rk <= outerRMath + 1e-6 then
local rPx = (rk / xSpan) * plotW
local rLbl = formatTickValue(rk, xUnit, xUnitLabel, tickFracX, xFmt)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-rtk">%s</text>', cx + rPx * cos(0.38), cy - rPx * sin(0.38) + 3, esc(rLbl))
end
end
end
else
clipTag = format('<clipPath id="%s"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath>', clipId, plotL, plotT, plotW, plotH)
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bg"/>', plotL, plotT, plotW, plotH)
local xTicks = ax.xticks or buildAxisTicks(xMin, xMax, xTickStep, xUnit, hasXUnit, strictTicks, 6)
local yTicks = ax.yticks or buildAxisTicks(yMin, yMax, yTickStep, yUnit, hasYUnit, strictTicks, 6)
local xLabels = ax.xticklabels
local yLabels = ax.yticklabels
local xtickFs = tonumber(ax.xtick_fontsize) or 10
local ytickFs = tonumber(ax.ytick_fontsize) or 10
for idx, xVal in ipairs(xTicks) do
local px = mapX(xVal)
if px >= plotL - 0.5 and px <= plotL + plotW + 0.5 then
if (not isPolarRect) and showXGrid and px > plotL + 1 and px < plotL + plotW - 1 then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="mpl-grd"/>', px, plotT, px, plotT + plotH)
end
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#333" stroke-width="1"/>', px, plotT + plotH, px, plotT + plotH + 3.5)
if not hideXLabels then
local lbl = (xLabels and xLabels[idx] ~= nil) and tostring(xLabels[idx]) or formatTickValue(xVal, xUnit, xUnitLabel, tickFracX, xFmt)
if useHtmlLabels and hasLocalizableText(lbl) then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>', px, plotT + plotH + 11, xtickFs, lbl)
else
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', px, plotT + plotH + 14.5, esc(lbl))
end
end
end
end
for idx, yVal in ipairs(yTicks) do
local py = mapY(yVal)
if py >= plotT - 0.5 and py <= plotT + plotH + 0.5 then
if (not isPolarRect) and showYGrid and py > plotT + 1 and py < plotT + plotH - 1 then
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="mpl-grd"/>', plotL, py, plotL + plotW, py)
end
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#333" stroke-width="1"/>', plotL - 3.5, py, plotL, py)
if not hideYLabels then
local lbl = (yLabels and yLabels[idx] ~= nil) and tostring(yLabels[idx]) or formatTickValue(yVal, yUnit, yUnitLabel, tickFracY, yFmt)
if useHtmlLabels and hasLocalizableText(lbl) then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-r" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;">%s</span>', plotL - 5, py, ytickFs, lbl)
else
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="mpl-tk">%s</text>', plotL - 5.5, py + 3.5, esc(lbl))
end
end
end
end
if isPolarRect then
local cx, cy = mapX(0), mapY(0)
local fitRMath = min(abs(xMin), abs(xMax), abs(yMin), abs(yMax))
local baseRTicks = buildAxisTicks(0, fitRMath, xTickStep or yTickStep, xUnit, hasXUnit, true, 4)
local rStep = (#baseRTicks >= 2) and (baseRTicks[2] - baseRTicks[1]) or (fitRMath / 4)
if rStep <= 0 then rStep = 1 end
local refRMath = baseRTicks[#baseRTicks] or fitRMath
if refRMath > fitRMath * 0.96 and #baseRTicks >= 2 then refRMath = baseRTicks[#baseRTicks - 1] end
local refRPx = (refRMath / xSpan) * plotW
local maxCornerR = sqrt(max(xMin * xMin, xMax * xMax) + max(yMin * yMin, yMax * yMax))
local maxCornerPx = (maxCornerR / xSpan) * plotW
buf[#buf + 1] = format('<g clip-path="url(#%s)">', clipId)
if showGrid then
local rk, safety = rStep, 0
while rk <= maxCornerR + rStep * 0.5 and safety < 60 do
safety = safety + 1
local rPx = (rk / xSpan) * plotW
local isRefCircle = (abs(rk - refRMath) < rStep * 0.1)
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" class="%s"/>', cx, cy, rPx, isRefCircle and "mpl-pref" or "mpl-pgrd")
rk = rk + rStep
end
end
local angleStep = (refRPx < 75) and 45 or 30
local angleLabels = (angleStep == 45) and {
[0] = "0", [45] = "π/4", [90] = "π/2", [135] = "3π/4",
[180] = "π", [225] = "5π/4", [270] = "3π/2", [315] = "7π/4"
} or {
[0] = "0", [30] = "π/6", [60] = "π/3", [90] = "π/2",
[120] = "2π/3", [150] = "5π/6", [180] = "π", [210] = "7π/6",
[240] = "4π/3", [270] = "3π/2", [300] = "5π/3", [330] = "11π/6"
}
for deg = 0, 359, angleStep do
local rad = deg * pi / 180
local cosA, sinA = cos(rad), sin(rad)
local ex, ey = cx + maxCornerPx * cosA, cy - maxCornerPx * sinA
if showGrid and (deg % 90 ~= 0) then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-pgrd"/>', cx, cy, ex, ey)
end
end
if yMin <= 0 and yMax >= 0 then buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="mpl-ax"/>', plotL, cy, plotL + plotW, cy) end
if xMin <= 0 and xMax >= 0 then buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="mpl-ax"/>', cx, plotT, cx, plotT + plotH) end
for deg = 0, 359, angleStep do
local rad = deg * pi / 180
local cosA, sinA = cos(rad), sin(rad)
local lblR = refRPx + 11
local lx = max(plotL + 14, min(plotL + plotW - 14, cx + lblR * cosA))
local ly = max(plotT + 11, min(plotT + plotH - 4, cy - lblR * sinA + 3.5))
local anchor = "middle"
if cosA > 0.35 then anchor = "start"; lx = lx - 2
elseif cosA < -0.35 then anchor = "end"; lx = lx + 2 end
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-ptk">%s</text>', lx, ly, anchor, angleLabels[deg] or (deg .. "°"))
end
local rk = rStep
while rk <= refRMath + 1e-6 do
local rPx = (rk / xSpan) * plotW
if cx + rPx < plotL + plotW - 18 then
local rLbl = formatTickValue(rk, xUnit, xUnitLabel, tickFracX, xFmt)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-rtk">%s</text>', cx + rPx, cy + 11, esc(rLbl))
end
rk = rk + rStep
end
buf[#buf + 1] = '</g>'
elseif ax.origin_axes then
if yMin <= 0 and yMax >= 0 then
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" class="mpl-ax"/>', plotL, mapY(0), plotL + plotW, mapY(0))
end
if xMin <= 0 and xMax >= 0 then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" class="mpl-ax"/>', mapX(0), plotT, mapX(0), plotT + plotH)
end
end
end
-- 委派 ChartRenderer 繪製所有資料序列 (包覆於 clipPath 內)
buf[#buf + 1] = format('<g clip-path="url(#%s)">', clipId)
local lastCmap, lastVMin, lastVMax = ChartRenderer.render2DSeries(ax, series_list, {
buf = buf,
overlays = overlays,
customState = customState,
palette = palette,
clipId = clipId,
animateMode = animateMode,
isPolarCoord = isPolarCoord,
plotL = plotL, plotT = plotT, plotW = plotW, plotH = plotH,
xMin = xMin, xMax = xMax, yMin = yMin, yMax = yMax,
xSpan = xSpan, ySpan = ySpan,
xUnit = xUnit, xUnitLabel = xUnitLabel,
yUnit = yUnit, yUnitLabel = yUnitLabel,
tickFracX = tickFracX, tickFracY = tickFracY,
xFmt = xFmt, yFmt = yFmt,
mapX = mapX, mapY = mapY
})
buf[#buf + 1] = '</g>'
if isPolarCircle then
if polarOuterCircleSvg then buf[#buf + 1] = polarOuterCircleSvg end
else
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bdr"/>', plotL, plotT, plotW, plotH)
end
-- 若開啟了熱圖色階條 (colorbar),繪製於繪圖區右側
if hasColorbar then
local cbX = plotL + plotW + 10
local cbW = 11
local cbSteps = 32
local stepH = plotH / cbSteps
for st = 1, cbSteps do
local tNorm = 1.0 - (st - 0.5) / cbSteps
local colHex = sampleColormap(lastCmap, tNorm, 1.0)
buf[#buf + 1] = format('<rect x="%.1f" y="%.2f" width="%.1f" height="%.2f" fill="%s"/>', cbX, plotT + (st - 1) * stepH, cbW, stepH + 0.4, colHex)
end
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bdr"/>', cbX, plotT, cbW, plotH)
local cbTicks = buildAxisTicks(lastVMin, lastVMax, nil, nil, false, false, 4)
local vSpan = max(1e-9, lastVMax - lastVMin)
for _, tv in ipairs(cbTicks) do
if tv >= lastVMin - 1e-6 and tv <= lastVMax + 1e-6 then
local py = plotT + plotH - ((tv - lastVMin) / vSpan) * plotH
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#333" stroke-width="1"/>', cbX + cbW, py, cbX + cbW + 3, py)
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" class="mpl-tk">%s</text>', cbX + cbW + 5, py + 3.5, esc(format("%g", tv)))
end
end
end
-- 繪製自訂文字與箭頭註解 (ax.text / ax.annotate)
for _, tItem in ipairs(ax.texts or {}) do
local vx, vy = tonumber(tItem.x) or 0, tonumber(tItem.y) or 0
if isPolarCoord then
local th, r = vx, vy
vx, vy = r * cos(th), r * sin(th)
end
local tx, ty = mapX(vx), mapY(vy)
local tCol = parseColor(tItem.color, "#222222")
local fs = tonumber(tItem.fontsize) or 11
if tItem.arrow_to then
local ax0, ay0 = tonumber(tItem.arrow_to[1]) or 0, tonumber(tItem.arrow_to[2]) or 0
if isPolarCoord then ax0, ay0 = ay0 * cos(ax0), ay0 * sin(ax0) end
local axTo, ayTo = mapX(ax0), mapY(ay0)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2"/>', tx, ty, axTo, ayTo, tCol)
buf[#buf + 1] = renderSymbol("circle", axTo, ayTo, 2.5, tCol, tCol, 0, 1, customState)
end
if useHtmlLabels then
local alignCls = (tItem.ha == "center") and "mpl-c" or ((tItem.ha == "right") and "mpl-r" or "mpl-v")
overlays[#overlays + 1] = format('<span class="mpl-ov %s" style="left:%.1fpx;top:%.1fpx;font-size:%.1fpx;color:%s;">%s</span>', alignCls, tx, ty, fs, tCol, tostring(tItem.text or ""))
else
local anc = (tItem.ha == "center") and "middle" or ((tItem.ha == "right") and "end" or "start")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-family="sans-serif" font-size="%.1fpx" fill="%s">%s</text>', tx, ty + 3.5, anc, fs, tCol, esc(tItem.text))
end
end
appendSubplotTitle(ax, box, plotL, plotW, plotT, buf, overlays, useHtmlLabels)
if xLabel and xLabel ~= "" then
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', plotL + plotW * 0.5, plotT + plotH + 27, tostring(xLabel))
else
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" fill="#2b2b2b">%s</text>', plotL + plotW * 0.5, plotT + plotH + 31, esc(xLabel))
end
end
if yLabel and yLabel ~= "" then
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-rot-y mpl-fs12" style="left:%.1fpx;top:%.1fpx;">%s</span>', box.x + 12, plotT + plotH * 0.5, tostring(yLabel))
else
local xc, yc = box.x + 13, plotT + plotH * 0.5
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" fill="#2b2b2b" transform="rotate(-90, %.1f, %.1f)">%s</text>', xc, yc, xc, yc, esc(yLabel))
end
end
if showLegend and legItems then
local legX, legY
if isOutsideLegend then
legX, legY = plotL + plotW + 8, plotT
elseif isPolarCircle then
legX, legY = box.x + box.w - legBoxW - 6, box.y + 8
elseif legLoc == "upper left" or legLoc == "top-left" then
legX, legY = plotL + 8, plotT + 8
elseif legLoc == "lower right" or legLoc == "bottom-right" then
legX, legY = plotL + plotW - legBoxW - 8, plotT + plotH - legBoxH - 8
elseif legLoc == "lower left" or legLoc == "bottom-left" then
legX, legY = plotL + 8, plotT + plotH - legBoxH - 8
else
legX, legY = plotL + plotW - legBoxW - 8, plotT + 8
end
appendAxesLegend(buf, overlays, legItems, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
end
return clipTag
end
-- ==========================================
-- 3. 3D 空間座標子圖渲染器 (projection = '3d')
-- ==========================================
local function render3DAxes(ax, box, subplotIdx, buf, overlays, customState, useHtmlLabels, finalW, finalH)
subplotIdx = subplotIdx or 1
local palette = ax.palette or DEFAULT_COLORS
local series_list = ax.series or {}
local showLegend = (ax.show_legend == true)
local legItems, legBoxW, legBoxH, legFontSize = nil, 0, 0, 11.0
if showLegend then
legItems, legBoxW, legBoxH, legFontSize = prepareAxesLegend(ax, palette, useHtmlLabels)
end
local legLoc = string.lower(tostring(ax.legend_loc or "upper right"))
local isOutsideLegend = showLegend and legItems and (legLoc == "right" or legLoc == "outside")
local hasTitle = (ax.title and ax.title ~= "")
local padL = 34
local padR = isOutsideLegend and (legBoxW + 12) or 18
local padT = hasTitle and 28 or 14
local padB = 24
local plotW = max(100, box.w - padL - padR)
local plotH = max(100, box.h - padT - padB)
local plotL = box.x + padL
local plotT = box.y + padT
local cx = plotL + plotW * 0.52
local cy = plotT + plotH * 0.50
local plotRadius = min(plotW, plotH) * 0.37
local bounds = MathEngine.compute3DBounds(series_list, ax)
local cam = MathEngine.buildCamera(bounds, { cx, cy }, plotRadius, {
elev = ax.elev or ax.view_phi,
azim = ax.azim or ax.view_theta,
roll = ax.roll or ax.view_roll,
proj_type = ax.proj_type or ax.projection_type,
view_dist = ax.view_dist,
equal_scale = (ax.equal_scale == true) or (ax.aspect == "equal"),
box_aspect = ax.box_aspect
})
local boxGeo = MathEngine.buildBoxGeometry(cam)
local xTicks = ax.xticks or getComplexTicks(bounds.xMin, bounds.xMax, ax.x_tick_step, 5)
local yTicks = ax.yticks or getComplexTicks(bounds.yMin, bounds.yMax, ax.y_tick_step, 5)
local zTicks = ax.zticks or getComplexTicks(bounds.zMin, bounds.zMax, ax.z_tick_step, 5)
local showAxes = (ax.show_axes ~= false)
local showGrid = (ax.grid ~= false)
local bx, by, bz = cam.boxScale[1], cam.boxScale[2], cam.boxScale[3]
if showAxes then
for _, face in ipairs(boxGeo.backFaces) do
local c = face.corners
local x1, y1 = cam.projectNorm(c[1][1], c[1][2], c[1][3])
local x2, y2 = cam.projectNorm(c[2][1], c[2][2], c[2][3])
local x3, y3 = cam.projectNorm(c[3][1], c[3][2], c[3][3])
local x4, y4 = cam.projectNorm(c[4][1], c[4][2], c[4][3])
buf[#buf + 1] = format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="#f5f5f5" stroke="#dcdcdc" stroke-width="0.9"/>', x1, y1, x2, y2, x3, y3, x4, y4)
end
if showGrid then
for _, xv in ipairs(xTicks) do
local nx = cam.toNorm(xv, bounds.yMin, bounds.zMin)
local x1, y1 = cam.projectNorm(nx, -by, boxGeo.backZ)
local x2, y2 = cam.projectNorm(nx, by, boxGeo.backZ)
local x3, y3 = cam.projectNorm(nx, boxGeo.backY, -bz)
local x4, y4 = cam.projectNorm(nx, boxGeo.backY, bz)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x1, y1, x2, y2)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x3, y3, x4, y4)
end
for _, yv in ipairs(yTicks) do
local _, ny = cam.toNorm(bounds.xMin, yv, bounds.zMin)
local x1, y1 = cam.projectNorm(-bx, ny, boxGeo.backZ)
local x2, y2 = cam.projectNorm(bx, ny, boxGeo.backZ)
local x3, y3 = cam.projectNorm(boxGeo.backX, ny, -bz)
local x4, y4 = cam.projectNorm(boxGeo.backX, ny, bz)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x1, y1, x2, y2)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x3, y3, x4, y4)
end
for _, zv in ipairs(zTicks) do
local _, _, nz = cam.toNorm(bounds.xMin, bounds.yMin, zv)
local x1, y1 = cam.projectNorm(-bx, boxGeo.backY, nz)
local x2, y2 = cam.projectNorm(bx, boxGeo.backY, nz)
local x3, y3 = cam.projectNorm(boxGeo.backX, -by, nz)
local x4, y4 = cam.projectNorm(boxGeo.backX, by, nz)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x1, y1, x2, y2)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-grd"/>', x3, y3, x4, y4)
end
end
end
local primitives = MathEngine.buildScenePrimitives(series_list, bounds, cam, palette, {
colormap = ax.colormap
})
for _, prim in ipairs(primitives) do
local opAttr = (prim.alpha and prim.alpha < 0.99) and format(' fill-opacity="%.2f" stroke-opacity="%.2f"', prim.alpha, prim.alpha) or ''
if prim.kind == "quad" then
buf[#buf + 1] = format('<polygon points="%s" fill="%s" stroke="%s" stroke-width="%.2f" stroke-linejoin="round"%s/>', prim.pts, prim.fill, prim.stroke, prim.strokeW, opAttr)
elseif prim.kind == "segment" then
local dashAttr = prim.dash and format(' stroke-dasharray="%s"', prim.dash) or ''
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f" stroke-linecap="round"%s%s/>', prim.x1, prim.y1, prim.x2, prim.y2, prim.stroke, prim.strokeW, dashAttr, opAttr)
elseif prim.kind == "point" then
buf[#buf + 1] = renderSymbol(prim.shape, prim.cx, prim.cy, prim.r, prim.fill, prim.stroke, prim.strokeW, prim.alpha, customState)
end
end
if showAxes then
local axX, axY, axZ = boxGeo.axes.x, boxGeo.axes.y, boxGeo.axes.z
local tLen = 0.06
local xLabel = ax.xlabel or "x"
local yLabel = ax.ylabel or "y"
local zLabel = ax.zlabel or "z"
local xx1, xy1 = cam.projectNorm(-bx, axX.yNorm, axX.zNorm)
local xx2, xy2 = cam.projectNorm(bx, axX.yNorm, axX.zNorm)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-ax"/>', xx1, xy1, xx2, xy2)
for _, xv in ipairs(xTicks) do
local nx = cam.toNorm(xv, bounds.yMin, bounds.zMin)
local p0x, p0y = cam.projectNorm(nx, axX.yNorm, axX.zNorm)
local p1x, p1y = cam.projectNorm(nx, axX.yNorm + axX.tickVec[2] * tLen, axX.zNorm)
local plx, ply = cam.projectNorm(nx, axX.yNorm + axX.tickVec[2] * (tLen * 2.8), axX.zNorm)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', p0x, p0y, p1x, p1y)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', plx, ply + 4, esc(format("%g", xv)))
end
if xLabel ~= "" then
local lx, ly = cam.projectNorm(0, axX.yNorm + axX.tickVec[2] * 0.42, axX.zNorm - 0.06)
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" data-fg-axis="x" data-subplot-idx="%d" style="left:%.1fpx;top:%.1fpx;">%s</span>', subplotIdx, lx, ly, tostring(xLabel))
else
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" class="mpl-tk">%s</text>', lx, ly, esc(xLabel))
end
end
local yx1, yy1 = cam.projectNorm(axY.xNorm, -by, axY.zNorm)
local yx2, yy2 = cam.projectNorm(axY.xNorm, by, axY.zNorm)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-ax"/>', yx1, yy1, yx2, yy2)
for _, yv in ipairs(yTicks) do
local _, ny = cam.toNorm(bounds.xMin, yv, bounds.zMin)
local p0x, p0y = cam.projectNorm(axY.xNorm, ny, axY.zNorm)
local p1x, p1y = cam.projectNorm(axY.xNorm + axY.tickVec[1] * tLen, ny, axY.zNorm)
local plx, ply = cam.projectNorm(axY.xNorm + axY.tickVec[1] * (tLen * 2.8), ny, axY.zNorm)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', p0x, p0y, p1x, p1y)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', plx, ply + 4, esc(format("%g", yv)))
end
if yLabel ~= "" then
local lx, ly = cam.projectNorm(axY.xNorm + axY.tickVec[1] * 0.42, 0, axY.zNorm - 0.06)
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" data-fg-axis="y" data-subplot-idx="%d" style="left:%.1fpx;top:%.1fpx;">%s</span>', subplotIdx, lx, ly, tostring(yLabel))
else
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" class="mpl-tk">%s</text>', lx, ly, esc(yLabel))
end
end
local zx1, zy1 = cam.projectNorm(axZ.xNorm, axZ.yNorm, -bz)
local zx2, zy2 = cam.projectNorm(axZ.xNorm, axZ.yNorm, bz)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" class="mpl-ax"/>', zx1, zy1, zx2, zy2)
for _, zv in ipairs(zTicks) do
local _, _, nz = cam.toNorm(bounds.xMin, bounds.yMin, zv)
local p0x, p0y = cam.projectNorm(axZ.xNorm, axZ.yNorm, nz)
local p1x, p1y = cam.projectNorm(axZ.xNorm + axZ.tickVec[1] * tLen, axZ.yNorm + axZ.tickVec[2] * tLen, nz)
local plx, ply = cam.projectNorm(axZ.xNorm + axZ.tickVec[1] * (tLen * 2.2), axZ.yNorm + axZ.tickVec[2] * (tLen * 2.2), nz)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', p0x, p0y, p1x, p1y)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="mpl-tk">%s</text>', plx - 3, ply + 3.5, esc(format("%g", zv)))
end
if zLabel ~= "" then
local lx, ly = cam.projectNorm(axZ.xNorm + axZ.tickVec[1] * 0.36, axZ.yNorm + axZ.tickVec[2] * 0.36, bz + 0.14)
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs12" data-fg-axis="z" data-subplot-idx="%d" style="left:%.1fpx;top:%.1fpx;">%s</span>', subplotIdx, max(box.x + 14, lx), max(box.y + 12, ly), tostring(zLabel))
else
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" class="mpl-tk">%s</text>', max(box.x + 14, lx), max(box.y + 12, ly), esc(zLabel))
end
end
end
appendSubplotTitle(ax, box, plotL, plotW, plotT, buf, overlays, useHtmlLabels)
local legSvgBuf = {}
if showLegend and legItems then
local legX = isOutsideLegend and (plotL + plotW + 6) or (plotL + plotW - legBoxW - 6)
local legY = plotT + 4
appendAxesLegend(legSvgBuf, overlays, legItems, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
for k = 1, #legSvgBuf do
buf[#buf + 1] = legSvgBuf[k]
end
end
local packedPrims = {}
for i = 1, #primitives do
local pr = primitives[i]
if pr.kind == "quad" and pr.pts3D then
packedPrims[#packedPrims + 1] = {
k = "q", s = pr.sIdx or 1, p = pr.pts3D, n = pr.norm,
c = pr.baseHex, ns = pr.noShade == true and true or nil,
ec = pr.edgeCol, sm = pr.showMesh, w = pr.strokeW,
a = pr.alpha, tc = pr.tipCoord, tv = pr.tipVal
}
elseif pr.kind == "segment" and pr.p0 and pr.p1 then
packedPrims[#packedPrims + 1] = {
k = "s", s = pr.sIdx or 1, p0 = pr.p0, p1 = pr.p1,
c = pr.stroke, w = pr.strokeW, d = pr.dash,
a = pr.alpha, tc = pr.tipCoord, tv = pr.tipVal
}
elseif pr.kind == "point" and pr.pt3D then
packedPrims[#packedPrims + 1] = {
k = "p", s = pr.sIdx or 1, pt = pr.pt3D, r = pr.r,
sh = pr.shape, c = pr.fill, ec = pr.stroke,
w = pr.strokeW, a = pr.alpha, tc = pr.tipCoord, tv = pr.tipVal
}
end
end
local seriesTitles = {}
local i18nParts = {
format('<div class="fg-3d-i18n" data-subplot-idx="%d" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;pointer-events:none;opacity:0;" aria-hidden="true">', subplotIdx),
'<span data-fg-i18n-key="_ui_drag_3d">⟳ 可拖曳旋轉 3D 視角</span>'
}
for sIdx, s in ipairs(series_list) do
local rawLbl = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or (ax.zlabel or "z")
seriesTitles[sIdx] = rawLbl
i18nParts[#i18nParts + 1] = format('<div class="fg-3d-math-src" data-subplot-idx="%d" data-fg-series-title="%d"><b>%s</b></div>', subplotIdx, sIdx, expandMathTags(rawLbl))
end
i18nParts[#i18nParts + 1] = '</div>'
overlays[#overlays + 1] = table.concat(i18nParts)
local xLblStr = showAxes and (ax.xlabel or "x") or nil
local yLblStr = showAxes and (ax.ylabel or "y") or nil
local zLblStr = showAxes and (ax.zlabel or "z") or nil
local function copyNumArray(arr)
local out = {}
for i = 1, #(arr or {}) do out[i] = tonumber(arr[i]) or 0 end
return out
end
return {
subplotIdx = subplotIdx,
groupId = "mpl-subplot-" .. subplotIdx,
box = { x = box.x, y = box.y, w = box.w, h = box.h },
finalW = finalW or box.w,
finalH = finalH or box.h,
padL = padL, padT = padT, plotW = plotW, plotH = plotH,
cx = cx, cy = cy, plotRadius = plotRadius,
bounds = {
xMin = bounds.xMin, xMax = bounds.xMax,
yMin = bounds.yMin, yMax = bounds.yMax,
zMin = bounds.zMin, zMax = bounds.zMax
},
boxScale = { cam.boxScale[1], cam.boxScale[2], cam.boxScale[3] },
viewTheta = tonumber(ax.azim or ax.view_theta) or -60,
viewPhi = tonumber(ax.elev or ax.view_phi) or 28,
viewRoll = tonumber(ax.roll or ax.view_roll) or 0,
projection = string.lower(tostring(ax.proj_type or ax.projection_type or "ortho")),
viewDist = tonumber(ax.view_dist) or 4.5,
showAxes = showAxes,
showGrid = showGrid,
showMesh = true,
useHtmlLabels = useHtmlLabels,
tickFontSize = "10px",
xTicks = copyNumArray(xTicks),
yTicks = copyNumArray(yTicks),
zTicks = copyNumArray(zTicks),
xLabel = (xLblStr and xLblStr ~= "") and tostring(xLblStr) or nil,
yLabel = (yLblStr and yLblStr ~= "") and tostring(yLblStr) or nil,
zLabel = (zLblStr and zLblStr ~= "") and tostring(zLblStr) or nil,
title = (not useHtmlLabels and hasTitle) and tostring(ax.title) or nil,
seriesTitles = seriesTitles,
legendSvg = (#legSvgBuf > 0) and table.concat(legSvgBuf) or nil,
prims = packedPrims
}
end
-- ==========================================
-- 4. 複變函數定義域著色子圖渲染器 (projection = 'complex')
-- ==========================================
local function renderComplexAxes(ax, box, clipId, buf, overlays, useHtmlLabels)
local cd = ax.complex_data or {}
local masterAxes = (cd.axes ~= false and ax.show_axes ~= false)
local showInnerAxes = masterAxes and (cd.inner_axes ~= false)
local showInnerTicks = masterAxes and (cd.inner_ticks == true)
local showInnerTickLabels = masterAxes and (cd.inner_tick_labels == true)
local showOuterTicks = masterAxes and (cd.outer_ticks ~= false)
local showTickLabels = masterAxes and (cd.tick_labels ~= false)
local showAxisTitles = masterAxes and (cd.axis_titles ~= false)
local showGrid = (cd.grid == true) or (ax.grid == true)
local gridStyle = string.lower(tostring(cd.grid_style or ax.grid_style or "dashed"))
local imagSuffix = tostring(cd.imag_suffix or "")
local xTitle = ax.xlabel or cd.xlabel or "實軸 (Re)"
local yTitle = ax.ylabel or cd.ylabel or "虛軸 (Im)"
if ax.xlabel == "" or cd.xlabel == "" then xTitle = nil end
if ax.ylabel == "" or cd.ylabel == "" then yTitle = nil end
local hasTitle = (ax.title and ax.title ~= "")
local padL, padB = 8, 8
local padT = hasTitle and 26 or 10
local padR = showTickLabels and 14 or 8
if showOuterTicks then padL = padL + 4; padB = padB + 4 end
if showTickLabels then padL = padL + 28; padB = padB + 16 end
if showAxisTitles and yTitle then padL = padL + 18 end
if showAxisTitles and xTitle then padB = padB + 18 end
local availW = max(60, box.w - padL - padR)
local availH = max(60, box.h - padT - padB)
local plotSize = min(availW, availH)
local plotL = box.x + padL + (availW - plotSize) * 0.5
local plotT = box.y + padT + (availH - plotSize) * 0.5
local xMin = tonumber(cd.x_start) or -5
local xMax = tonumber(cd.x_end) or 5
local yMin = tonumber(cd.y_start) or -5
local yMax = tonumber(cd.y_end) or 5
if xMin == xMax then xMax = xMin + 1 end
if yMin == yMax then yMax = yMin + 1 end
local xSpan, ySpan = xMax - xMin, yMax - yMin
local function mapX(x) return plotL + ((x - xMin) / xSpan) * plotSize end
local function mapY(y) return plotT + plotSize - ((y - yMin) / ySpan) * plotSize end
local clipTag = format('<clipPath id="%s"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath>', clipId, plotL, plotT, plotSize, plotSize)
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#7f7f7f"/>', plotL, plotT, plotSize, plotSize)
local pixels = cd.pixels or {}
local nRows = #pixels
local nCols = (nRows > 0 and #pixels[1]) or 1
local cellW, cellH = plotSize / max(1, nCols), plotSize / max(1, nRows)
buf[#buf + 1] = '<g shape-rendering="crispEdges">'
for r = 1, nRows do
local row = pixels[r]
local py = plotT + (r - 1) * cellH
local c = 1
while c <= nCols do
local colColor = row[c]
local runLen = 1
while (c + runLen <= nCols) and (row[c + runLen] == colColor) do runLen = runLen + 1 end
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>', plotL + (c - 1) * cellW, py, runLen * cellW, cellH, colColor)
c = c + runLen
end
end
buf[#buf + 1] = '</g>'
local targetTicks = max(4, min(8, floor(plotSize / 32)))
local xTicks = getComplexTicks(xMin, xMax, ax.x_tick_step, targetTicks)
local yTicks = getComplexTicks(yMin, yMax, ax.y_tick_step, targetTicks)
local xUnit = tonumber(ax.x_unit) or 1.0
local xUnitLabel = mw.text.trim(tostring(ax.x_unit_label or ""))
local yUnit = tonumber(ax.y_unit) or 1.0
local yUnitLabel = mw.text.trim(tostring(ax.y_unit_label or ""))
local useFracX = (ax.tick_fraction == true) or (xUnitLabel ~= "")
local useFracY = (ax.tick_fraction == true) or (yUnitLabel ~= "")
buf[#buf + 1] = format('<g clip-path="url(#%s)">', clipId)
local dashAttr = (gridStyle == "solid") and "" or ' stroke-dasharray="3,2"'
if showGrid then
for _, tk in ipairs(xTicks) do
if not (showInnerAxes and abs(tk) < 1e-10) then
local px = mapX(tk)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#111111" stroke-opacity="0.38" stroke-width="0.8"%s/>', px, plotT, px, plotT + plotSize, dashAttr)
end
end
for _, tk in ipairs(yTicks) do
if not (showInnerAxes and abs(tk) < 1e-10) then
local py = mapY(tk)
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#111111" stroke-opacity="0.38" stroke-width="0.8"%s/>', plotL, py, plotL + plotSize, py, dashAttr)
end
end
end
local px0, py0 = mapX(0), mapY(0)
local hasXAxisIn = (yMin <= 0 and yMax >= 0)
local hasYAxisIn = (xMin <= 0 and xMax >= 0)
if showInnerAxes then
if hasXAxisIn then buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#000000" stroke-width="1.3"/>', plotL, py0, plotL + plotSize, py0) end
if hasYAxisIn then buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#000000" stroke-width="1.3"/>', px0, plotT, px0, plotT + plotSize) end
end
if showInnerTicks then
if hasXAxisIn then
for _, tk in ipairs(xTicks) do
if abs(tk) > 1e-10 then
local px = mapX(tk)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#000000" stroke-width="1.1"/>', px, py0 - 3.5, px, py0 + 3.5)
if showInnerTickLabels then
local lbl = formatTickValue(tk, xUnit, xUnitLabel, useFracX, "%g")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-family="sans-serif" font-size="8.5px" fill="#000000">%s</text>', px, py0 + 12, esc(lbl))
end
end
end
end
if hasYAxisIn then
for _, tk in ipairs(yTicks) do
if abs(tk) > 1e-10 then
local py = mapY(tk)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#000000" stroke-width="1.1"/>', px0 - 3.5, py, px0 + 3.5, py)
if showInnerTickLabels then
local lbl = formatTickValue(tk, yUnit, yUnitLabel, useFracY, "%g") .. (tk ~= 0 and imagSuffix or "")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-family="sans-serif" font-size="8.5px" fill="#000000">%s</text>', px0 - 5, py + 3, esc(lbl))
end
end
end
end
end
buf[#buf + 1] = '</g>'
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" class="mpl-bdr"/>', plotL, plotT, plotSize, plotSize)
local tickLen = 4
for _, tk in ipairs(xTicks) do
local px = mapX(tk)
if px >= plotL - 0.5 and px <= plotL + plotSize + 0.5 then
if showOuterTicks then buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#2b2b2b" stroke-width="1"/>', px, plotT + plotSize, px, plotT + plotSize + tickLen) end
if showTickLabels then
local lbl = formatTickValue(tk, xUnit, xUnitLabel, useFracX, "%g")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-tk">%s</text>', px, plotT + plotSize + (showOuterTicks and 15 or 12), esc(lbl))
end
end
end
for _, tk in ipairs(yTicks) do
local py = mapY(tk)
if py >= plotT - 0.5 and py <= plotT + plotSize + 0.5 then
if showOuterTicks then buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#2b2b2b" stroke-width="1"/>', plotL - tickLen, py, plotL, py) end
if showTickLabels then
local lbl = formatTickValue(tk, yUnit, yUnitLabel, useFracY, "%g") .. (tk ~= 0 and imagSuffix or "")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" class="mpl-tk">%s</text>', plotL - (showOuterTicks and 7 or 4), py + 3.5, esc(lbl))
end
end
end
appendSubplotTitle(ax, box, plotL, plotSize, plotT, buf, overlays, useHtmlLabels)
if showAxisTitles and xTitle then
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-fs11" style="left:%.1fpx;top:%.1fpx;">%s</span>', plotL + plotSize * 0.5, plotT + plotSize + 27, tostring(xTitle))
else
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="#2b2b2b">%s</text>', plotL + plotSize * 0.5, plotT + plotSize + 31, esc(xTitle))
end
end
if showAxisTitles and yTitle then
local yc = plotT + plotSize * 0.5
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-rot-y mpl-fs11" style="left:%.1fpx;top:%.1fpx;">%s</span>', plotL - 36, yc, tostring(yTitle))
else
local xc = plotL - 35
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="#2b2b2b" transform="rotate(-90, %.1f, %.1f)">%s</text>', xc, yc, xc, yc, esc(yTitle))
end
end
return clipTag
end
-- ==========================================
-- 5. 頂層 Figure 組合渲染器 (支援多子圖 <g> 隔離與多組 3D 封裝)
-- ==========================================
function p.renderFigure(fig)
if mw.isSubsting() then
require("Module:PythonScript/Runtime").raise("NotImplementedError", "圖表與 SVG 渲染功能不支援在 subst (模板展開存成純 wikitext) 模式下使用")
end
fig = fig or {}
local finalW = floor((tonumber(fig.width) or 420) + 0.5)
local finalH = floor((tonumber(fig.height) or 300) + 0.5)
local nrows = max(1, floor(tonumber(fig.nrows) or 1))
local ncols = max(1, floor(tonumber(fig.ncols) or 1))
local useHtmlLabels = (fig.html_labels ~= false)
local animateMode = string.lower(tostring(fig.animate or "no"))
local topOffset = (fig.suptitle and fig.suptitle ~= "") and 28 or 0
local cellW = finalW / ncols
local cellH = max(80, (finalH - topOffset) / nrows)
local buf = {
"", -- 預留給 <defs>
format('<rect x="0" y="0" width="%d" height="%d" class="mpl-wbg"/>', finalW, finalH)
}
local overlays = {}
local customState = { count = 0, map = {}, defs = {} }
local clipDefs = {}
if fig.suptitle and fig.suptitle ~= "" then
if useHtmlLabels then
overlays[#overlays + 1] = format('<span class="mpl-ov mpl-c mpl-ttl" style="left:%.1fpx;top:16px;">%s</span>', finalW * 0.5, tostring(fig.suptitle))
else
buf[#buf + 1] = format('<text x="%.1f" y="21" text-anchor="middle" font-family="sans-serif" font-size="15px" font-weight="bold" fill="#2b2b2b">%s</text>', finalW * 0.5, esc(fig.suptitle))
end
end
local axes_list = fig.axes_list or {}
local subplots3D = {}
for idx, ax in ipairs(axes_list) do
local r = tonumber(ax.row) or (floor((idx - 1) / ncols) + 1)
local c = tonumber(ax.col) or (((idx - 1) % ncols) + 1)
local box = {
x = (c - 1) * cellW,
y = topOffset + (r - 1) * cellH,
w = cellW,
h = cellH
}
local clipId = "mpl-clip-" .. idx
local proj = string.lower(tostring(ax.projection or "rectilinear"))
local subBuf = {}
if proj == "3d" then
local subSpec3D = render3DAxes(ax, box, idx, subBuf, overlays, customState, useHtmlLabels, finalW, finalH)
if subSpec3D and type(subSpec3D.prims) == "table" and #subSpec3D.prims > 0 then
subplots3D[#subplots3D + 1] = subSpec3D
end
buf[#buf + 1] = format(
'<g id="mpl-subplot-%d" class="mpl-subplot-group" data-subplot-idx="%d" data-subplot-type="3d">%s</g>',
idx, idx, table.concat(subBuf)
)
elseif proj == "complex" then
local cTag = renderComplexAxes(ax, box, clipId, subBuf, overlays, useHtmlLabels)
if cTag then clipDefs[#clipDefs + 1] = cTag end
buf[#buf + 1] = format(
'<g id="mpl-subplot-%d" class="mpl-subplot-group" data-subplot-idx="%d" data-subplot-type="2d">%s</g>',
idx, idx, table.concat(subBuf)
)
elseif proj == "pie" then
ChartRenderer.renderPieAxes(ax, box, subBuf, overlays, customState, useHtmlLabels, svgHelpers)
buf[#buf + 1] = format(
'<g id="mpl-subplot-%d" class="mpl-subplot-group" data-subplot-idx="%d" data-subplot-type="2d">%s</g>',
idx, idx, table.concat(subBuf)
)
else
local cTag = render2DAxes(ax, box, clipId, subBuf, overlays, customState, useHtmlLabels, animateMode)
if cTag then clipDefs[#clipDefs + 1] = cTag end
buf[#buf + 1] = format(
'<g id="mpl-subplot-%d" class="mpl-subplot-group" data-subplot-idx="%d" data-subplot-type="2d">%s</g>',
idx, idx, table.concat(subBuf)
)
end
end
local desc3DNode = ""
local has3DSubplot = (#subplots3D > 0)
if has3DSubplot then
local mpl3DPayload = {
isFunctionGraph3D = true,
isMatplotlib3D = true,
finalW = finalW,
finalH = finalH,
customDefs = (#customState.defs > 0) and table.concat(customState.defs) or nil,
subplots3D = subplots3D,
prims = { 1 }
}
local okJson, rawJsonStr = pcall(mw.text.jsonEncode, mpl3DPayload)
if okJson and type(rawJsonStr) == "string" and rawJsonStr ~= "" then
local cleanJsonStr = string.gsub(rawJsonStr, "\127'\"`UNIQ.-QINU`\"'\127", "")
desc3DNode = format('<desc id="fg-3d-data">%s</desc>', mw.text.encode(cleanJsonStr))
else
desc3DNode = format('<desc id="fg-3d-error">%s</desc>', mw.text.encode(tostring(rawJsonStr)))
end
end
local internalStyle = buildInternalSvgStyle("10px", animateMode)
buf[1] = format('<defs>%s%s%s%s</defs>', internalStyle, table.concat(clipDefs), table.concat(customState.defs), desc3DNode)
local imgClass = has3DSubplot and 'mpl-svg function-graph-svg function-3d-svg' or 'mpl-svg function-graph-svg'
local svgImg
if mw and mw.svg and type(mw.svg.new) == "function" then
svgImg = mw.svg.new()
:setAttribute('viewBox', format('0 0 %d %d', finalW, finalH))
:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
:setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
:setImgAttribute('width', tostring(finalW))
:setImgAttribute('height', tostring(finalH))
:setImgAttribute('class', imgClass)
:setContent(table.concat(buf))
:toImage()
else
svgImg = format(
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 %d %d" width="%d" height="%d" class="mpl-svg">%s</svg>',
finalW, finalH, finalW, finalH, table.concat(buf)
)
end
for i = 1, #overlays do
overlays[i] = expandMathTags(overlays[i])
end
return wrapHybridOutput(svgImg, finalW, finalH, overlays)
end
return p