模組:Graph:MultiChart
外观
-- Module:Graph:MultiChart
local p = {}
local Graph = require('Module:Graph')
local TrackingCategory = require('Module:TrackingCategory')
--{{#invoke:Graph:MultiChart|mergeChart}}
local format, floor, ceil, abs, max, min = string.format, math.floor, math.ceil, math.abs, math.max, math.min
local log10, pow, sin, pi = math.log10, math.pow, math.sin, math.pi
local MONTH_NAMES = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }
local function esc(s)
return mw.text.encode(mw.text.unstrip(tostring(s or "")))
end
local function addCommas(numStr)
local sign, int, frac = string.match(numStr, "^([%+%-]?)(%d+)(%.?%d*)$")
if not int then return numStr end
int = string.gsub(string.reverse(int), "(%d%d%d)", "%1,")
int = string.gsub(string.reverse(int), "^,", "")
return sign .. int .. frac
end
local function formatNumber(val, fmt, isInt)
if val == nil or val ~= val then return "" end
if abs(val) < 1e-12 then val = 0 end
if not fmt or fmt == "" then
if isInt then return tostring(floor(val + 0.5)) end
return format("%g", tonumber(format("%.6g", val)) or val)
end
if string.sub(fmt, 1, 1) == "%" and not string.match(fmt, "^%%[d%.fFgGeE]") then
fmt = string.sub(fmt, 2)
end
local useComma = string.find(fmt, ",", 1, true) ~= nil
local cleanFmt = string.gsub(fmt, ",", "")
local out
if string.find(cleanFmt, "%%", 1, true) then
local prec = tonumber(string.match(cleanFmt, "%.(%d+)")) or 0
out = format("%." .. prec .. "f%%", val * 100)
elseif string.find(cleanFmt, "s", 1, true) then
local av = abs(val)
if av >= 1e9 then out = format("%.2gG", val / 1e9)
elseif av >= 1e6 then out = format("%.2gM", val / 1e6)
elseif av >= 1e3 then out = format("%.2gk", val / 1e3)
else out = format("%g", val) end
elseif cleanFmt == "d" or cleanFmt == "i" then
out = tostring(floor(val + 0.5))
else
local prec = tonumber(string.match(cleanFmt, "%.(%d+)"))
if prec then
out = format("%." .. prec .. "f", val)
else
local ok, res = pcall(format, "%" .. cleanFmt, val)
out = (ok and res) or format("%g", val)
end
end
if useComma then
local pct = ""
if string.sub(out, -1) == "%" then
pct = "%"
out = string.sub(out, 1, -2)
end
out = addCommas(out) .. pct
end
return out
end
local function parseDateValue(str)
if type(str) == "number" then return str end
str = mw.text.trim(tostring(str or ""))
local y, m, d = string.match(str, "^(%d%d%d%d)[%-%/](%d%d?)[%-%/](%d%d?)")
if y then return tonumber(y) + (tonumber(m) - 1) / 12 + (tonumber(d) - 1) / 365.25 end
y, m = string.match(str, "^(%d%d%d%d)[%-%/](%d%d?)$")
if y then return tonumber(y) + (tonumber(m) - 1) / 12 end
y = string.match(str, "^(%d%d%d%d)$")
if y then return tonumber(y) end
return nil
end
local function formatDateValue(val, fmt)
if not val then return "" end
local y = floor(val + 1e-6)
local rem = max(0, val - y)
local m = min(12, floor(rem * 12 + 1e-4) + 1)
local d = min(31, max(1, floor((rem - (m - 1) / 12) * 365.25 + 0.5) + 1))
if not fmt or fmt == "" then
if abs(val - y) < 1e-3 then return format("%04d", y) end
return format("%04d-%02d", y, m)
end
local out = fmt
out = string.gsub(out, "%%Y", format("%04d", y))
out = string.gsub(out, "%%y", format("%02d", y % 100))
out = string.gsub(out, "%%m", format("%02d", m))
out = string.gsub(out, "%%d", format("%02d", d))
out = string.gsub(out, "%%b", MONTH_NAMES[m] or "")
out = string.gsub(out, "%%B", MONTH_NAMES[m] or "")
return out
end
local function computeNiceTicks(dMin, dMax, maxTicks, scaleType, isInt)
if dMin > dMax then dMin, dMax = dMax, dMin end
if scaleType == "log" then
local pMin = floor(log10(max(1e-9, dMin)))
local pMax = ceil(log10(max(1e-8, dMax)))
if pMin == pMax then pMax = pMin + 1 end
local ticks = {}
local step = max(1, ceil((pMax - pMin) / maxTicks))
for pExp = pMin, pMax, step do ticks[#ticks + 1] = 10 ^ pExp end
return ticks, 10 ^ pMin, 10 ^ pMax
end
local span = dMax - dMin
if span <= 0 then span = 1 end
local rawStep = span / max(1, maxTicks)
local mag = 10 ^ floor(log10(rawStep))
local residual = rawStep / mag
local step
if residual > 5 then step = 10 * mag
elseif residual > 2 then step = 5 * mag
elseif residual > 1 then step = 2 * mag
else step = mag end
if isInt and step < 1 then step = 1 end
local niceMin = floor(dMin / step + 1e-9) * step
local niceMax = ceil(dMax / step - 1e-9) * step
if niceMin == niceMax then niceMax = niceMin + step end
local ticks = {}
for v = niceMin, niceMax + step * 0.1, step do
if abs(v) < 1e-12 then v = 0 end
ticks[#ticks + 1] = v
end
return ticks, niceMin, niceMax
end
local function applyScale(val, dMin, dMax, scaleType)
if scaleType == "log" then
local lMin = log10(max(1e-9, dMin))
local lMax = log10(max(1e-8, dMax))
return (log10(max(1e-9, val)) - lMin) / (lMax - lMin)
elseif scaleType == "sqrt" then
local sMin = (dMin >= 0 and math.sqrt(dMin) or -math.sqrt(-dMin))
local sMax = (dMax >= 0 and math.sqrt(dMax) or -math.sqrt(-dMax))
local sv = (val >= 0 and math.sqrt(val) or -math.sqrt(-val))
return (sv - sMin) / (sMax - sMin)
elseif string.match(scaleType or "", "^pow") then
local exp = tonumber(string.match(scaleType, "([%d%.]+)")) or 2
local pMin = pow(abs(dMin), exp) * (dMin < 0 and -1 or 1)
local pMax = pow(abs(dMax), exp) * (dMax < 0 and -1 or 1)
local pv = pow(abs(val), exp) * (val < 0 and -1 or 1)
return (pv - pMin) / (pMax - pMin)
end
return (val - dMin) / (dMax - dMin)
end
local function buildPathD(pts, interpolate)
local n = #pts
if n == 0 then return "" end
if n == 1 then return format("M %.2f,%.2f", pts[1][1], pts[1][2]) end
if interpolate == "step" or interpolate == "step-after" then
local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do b[#b + 1] = format("H %.2f V %.2f", pts[i][1], pts[i][2]) end
return table.concat(b, " ")
elseif interpolate == "step-before" then
local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do b[#b + 1] = format("V %.2f H %.2f", pts[i][2], pts[i][1]) end
return table.concat(b, " ")
elseif interpolate == "monotone" or interpolate == "cardinal" or interpolate == "basis" then
if n == 2 or n > 200 then
local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do b[#b + 1] = format("L %.2f,%.2f", pts[i][1], pts[i][2]) end
return table.concat(b, " ")
end
local dx_arr, m_arr = {}, {}
for i = 1, n - 1 do
local dx = pts[i + 1][1] - pts[i][1]
if dx <= 1e-5 then
local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for k = 2, n do b[#b + 1] = format("L %.2f,%.2f", pts[k][1], pts[k][2]) end
return table.concat(b, " ")
end
dx_arr[i] = dx
m_arr[i] = (pts[i + 1][2] - pts[i][2]) / dx
end
local t = { [1] = m_arr[1], [n] = m_arr[n - 1] }
for i = 2, n - 1 do
local m0, m1 = m_arr[i - 1], m_arr[i]
if m0 * m1 <= 0 then
t[i] = 0
else
local d0, d1 = dx_arr[i - 1], dx_arr[i]
local c = d0 + d1
t[i] = 3 * c / ((c + d1) / m0 + (c + d0) / m1)
end
end
local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 1, n - 1 do
local x0, y0, x1, y1 = pts[i][1], pts[i][2], pts[i + 1][1], pts[i + 1][2]
local dx3 = dx_arr[i] / 3
b[#b + 1] = format("C %.2f,%.2f %.2f,%.2f %.2f,%.2f", x0 + dx3, y0 + t[i] * dx3, x1 - dx3, y1 - t[i + 1] * dx3, x1, y1)
end
return table.concat(b, " ")
end
local b = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do b[#b + 1] = format("L %.2f,%.2f", pts[i][1], pts[i][2]) end
return table.concat(b, " ")
end
-- 解析單一子圖表座標軸與資料結構
local function prepareLayer(spec, plotW, plotH)
local chartType = spec.chartType or "line"
local stacked = spec.stacked
local xArr = spec.x or {}
local ySeries = spec.y or {}
local nSeries = #ySeries
local nPoints = xArr.n or #xArr
local isOrdinalX = (chartType == "rect") or (spec.xType == "string")
local isDateX = (not isOrdinalX) and (spec.xType == "date")
local xNums = {}
local dataXMin, dataXMax = math.huge, -math.huge
for j = 1, nPoints do
local rawX = xArr[j]
local xv
if isOrdinalX then xv = j
elseif isDateX then xv = parseDateValue(rawX)
elseif rawX ~= false and rawX ~= nil then xv = tonumber(rawX) end
xNums[j] = xv
if xv then
if xv < dataXMin then dataXMin = xv end
if xv > dataXMax then dataXMax = xv end
end
end
if dataXMin == math.huge then dataXMin, dataXMax = 0, max(1, nPoints) end
local stackedY0, stackedY1 = {}, {}
local dataYMin = (chartType ~= "line") and 0 or math.huge
local dataYMax = (chartType ~= "line") and 0 or -math.huge
for i = 1, nSeries do
stackedY0[i] = {}
stackedY1[i] = {}
end
for j = 1, nPoints do
local posSum, negSum = 0, 0
for i = 1, nSeries do
local rawY = ySeries[i] and ySeries[i][j]
local yv = (rawY ~= false and rawY ~= nil) and tonumber(rawY) or nil
if yv then
if stacked then
if yv >= 0 then
stackedY0[i][j] = posSum
posSum = posSum + yv
stackedY1[i][j] = posSum
else
stackedY0[i][j] = negSum
negSum = negSum + yv
stackedY1[i][j] = negSum
end
if stackedY1[i][j] < dataYMin then dataYMin = stackedY1[i][j] end
if stackedY1[i][j] > dataYMax then dataYMax = stackedY1[i][j] end
if stackedY0[i][j] < dataYMin then dataYMin = stackedY0[i][j] end
else
stackedY0[i][j] = 0
stackedY1[i][j] = yv
if yv < dataYMin then dataYMin = yv end
if yv > dataYMax then dataYMax = yv end
end
end
end
end
if dataYMin == math.huge then dataYMin, dataYMax = 0, 10 end
if dataYMin == dataYMax then dataYMax = dataYMin + 1 end
local xMin = (not isOrdinalX and spec.xMin and (isDateX and parseDateValue(spec.xMin) or tonumber(spec.xMin))) or dataXMin
local xMax = (not isOrdinalX and spec.xMax and (isDateX and parseDateValue(spec.xMax) or tonumber(spec.xMax))) or dataXMax
if xMin == xMax then xMax = xMin + 1 end
local xTicks = {}
if not isOrdinalX then
local maxXTicks = max(3, floor(plotW / 55))
xTicks = computeNiceTicks(xMin, xMax, maxXTicks, spec.xScaleType, spec.xType == "integer")
end
local yMin = tonumber(spec.yMin) or dataYMin
local yMax = tonumber(spec.yMax) or dataYMax
if spec.yScaleType == "log" and yMin <= 0 then yMin = 0.1 end
local maxYTicks = max(3, floor(plotH / 36))
local yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, maxYTicks, spec.yScaleType, spec.yType == "integer")
if not spec.yMin then yMin = niceYMin end
if not spec.yMax then yMax = niceYMax end
if yMin == yMax then yMax = yMin + 1 end
return {
spec = spec,
chartType = chartType,
stacked = stacked,
xArr = xArr,
ySeries = ySeries,
nSeries = nSeries,
nPoints = nPoints,
isOrdinalX = isOrdinalX,
isDateX = isDateX,
xNums = xNums,
stackedY0 = stackedY0,
stackedY1 = stackedY1,
xMin = xMin,
xMax = xMax,
xTicks = xTicks,
yMin = yMin,
yMax = yMax,
yTicks = yTicks
}
end
-- 子圖表入口:將子圖表參數序列化為 JSON 供外層 mergeChart 讀取
function p.subChart(frame)
local merged = {}
local parent = frame and frame.getParent and frame:getParent()
if parent and parent.args then
for k, v in pairs(parent.args) do
if v ~= "" then merged[tostring(k)] = v end
end
end
if frame and frame.args then
for k, v in pairs(frame.args) do
if v ~= "" then merged[tostring(k)] = v end
end
end
return mw.text.jsonEncode(merged)
end
-- 合併並渲染多重圖表 SVG
function p.mergeChart(frame)
local args = (type(frame) == "string") and { frame } or ((type((frame or {}).args) == "table") and frame.args or frame)
local jsonStr = args['1'] or args[1] or ""
if mw.text.trim(jsonStr) == "" then return "" end
local ok, decoded = pcall(mw.text.jsonDecode, jsonStr, mw.text.JSON_TRY_FIXING)
if not ok or type(decoded) ~= "table" then return "" end
if decoded[1] == nil then decoded = { decoded } end
if #decoded < 1 then return "" end
local specs = {}
local plotW, plotH = 200, 200
local colorOffset = 0
for i = 1, #decoded do
local item = decoded[i]
local spec = Graph.buildChartSpec(item)
-- 若第 2 個以後的子圖表未明確指定 colors,自動偏移預設色盤索引以區分不同圖層顏色
if i > 1 and not item.colors and spec.colors and #spec.colors > 1 then
local shiftedColors = {}
local shiftedAlphas = {}
for cIdx = 1, #spec.colors do
local srcIdx = (cIdx - 1 + colorOffset) % #spec.colors + 1
shiftedColors[cIdx] = spec.colors[srcIdx]
shiftedAlphas[cIdx] = spec.alphas[srcIdx]
end
spec.colors = shiftedColors
spec.alphas = shiftedAlphas
end
colorOffset = colorOffset + max(1, #(spec.y or {}))
if spec.width then plotW = max(40, spec.width) end
if spec.height then plotH = max(40, spec.height) end
specs[i] = spec
end
-- 若只有 1 個子圖表,直接交由標準渲染器輸出
if #specs == 1 then
return require('Module:Graph/SVGRenderer').renderChart(specs[1])
end
local layers = {}
for i = 1, #specs do
layers[i] = prepareLayer(specs[i], plotW, plotH)
end
local primaryLayer = layers[1]
local rightYLayer = layers[2] -- 依原版邏輯:第 2 張圖表的 Y 軸配置於右側 (orient="right")
local grey = primaryLayer.spec.persistentGrey or "#54595d"
-- 計算左 Y 軸、右 Y 軸與底部邊距
local maxLeftYLen = 3
for _, tk in ipairs(primaryLayer.yTicks) do
local s = formatNumber(tk, primaryLayer.spec.yAxisFormat, primaryLayer.spec.yType == "integer")
if #s > maxLeftYLen then maxLeftYLen = #s end
end
local maxRightYLen = 0
if rightYLayer then
for _, tk in ipairs(rightYLayer.yTicks) do
local s = formatNumber(tk, rightYLayer.spec.yAxisFormat, rightYLayer.spec.yType == "integer")
if #s > maxRightYLen then maxRightYLen = #s end
end
end
local xAngle = primaryLayer.spec.xAxisAngle or 0
local maxXLblLen = 4
if primaryLayer.isOrdinalX then
for j = 1, primaryLayer.nPoints do
local len = mw.ustring.len(tostring(primaryLayer.xArr[j] or ""))
if len > maxXLblLen then maxXLblLen = len end
end
end
local extraBottom = (abs(xAngle) > 10) and min(75, maxXLblLen * 5.5 * abs(sin(xAngle * pi / 180))) or 0
-- 收集所有圖例項目
local showAnyLegend = false
local legendTitle = nil
local legendEntries = {}
for i = 1, #layers do
local lyr = layers[i]
if lyr.spec.showLegend then
showAnyLegend = true
if lyr.spec.legendTitle and not legendTitle then
legendTitle = lyr.spec.legendTitle
end
end
for sIdx = 1, lyr.nSeries do
legendEntries[#legendEntries + 1] = {
title = lyr.spec.seriesTitles[sIdx] or ("y" .. #legendEntries + 1),
color = lyr.spec.colors[(sIdx - 1) % #lyr.spec.colors + 1],
chartType = lyr.chartType
}
end
end
local legW = 0
if showAnyLegend and #legendEntries > 0 then
for _, ent in ipairs(legendEntries) do
legW = max(legW, mw.ustring.len(ent.title) * 7.5 + 34)
end
if legendTitle then legW = max(legW, mw.ustring.len(legendTitle) * 8.5 + 20) end
legW = min(220, max(85, legW))
end
local rightAxisW = rightYLayer and (max(28, maxRightYLen * 6.8 + 12) + (rightYLayer.spec.yTitle and 20 or 0)) or 16
local chartTitle = primaryLayer.spec.title or (rightYLayer and rightYLayer.spec.title)
local padL = max(36, maxLeftYLen * 6.8 + 14) + (primaryLayer.spec.yTitle and 20 or 0)
local padR = rightAxisW + legW
local padT = chartTitle and 30 or 12
local padB = 26 + extraBottom + (primaryLayer.spec.xTitle and 20 or 0)
local totalW = padL + plotW + padR
local totalH = max(padT + plotH + padB, padT + (showAnyLegend and (#legendEntries * 19 + 30) or 0) + 12)
local function mapLayerX(lyr, val, idx)
if lyr.isOrdinalX then
if lyr.chartType == "rect" then
return padL + ((idx - 0.5) / max(1, lyr.nPoints)) * plotW
else
if lyr.nPoints <= 1 then return padL + plotW / 2 end
return padL + ((idx - 1) / (lyr.nPoints - 1)) * plotW
end
end
return padL + applyScale(val, lyr.xMin, lyr.xMax, lyr.spec.xScaleType) * plotW
end
local function mapLayerY(lyr, val)
return padT + plotH - applyScale(val, lyr.yMin, lyr.yMax, lyr.spec.yScaleType) * plotH
end
local buf = {}
buf[#buf + 1] = format('<defs><clipPath id="mc-clip"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath></defs>', padL, padT, plotW, plotH)
-- 1. 繪製左 Y 軸刻度與格線 (Layer 1)
for _, tk in ipairs(primaryLayer.yTicks) do
if tk >= primaryLayer.yMin - 1e-9 and tk <= primaryLayer.yMax + 1e-9 then
local py = mapLayerY(primaryLayer, tk)
if primaryLayer.spec.yGrid then
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#e1e3e5" stroke-width="1"/>', padL, py, padL + plotW, py)
end
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1"/>', padL - 5, py, padL, py, grey)
local lbl = formatNumber(tk, primaryLayer.spec.yAxisFormat, primaryLayer.spec.yType == "integer")
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', padL - 8, py + 3.8, grey, esc(lbl))
end
end
-- 2. 繪製右 Y 軸刻度 (Layer 2, orient="right")
if rightYLayer then
local rightX = padL + plotW
for _, tk in ipairs(rightYLayer.yTicks) do
if tk >= rightYLayer.yMin - 1e-9 and tk <= rightYLayer.yMax + 1e-9 then
local py = mapLayerY(rightYLayer, tk)
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1"/>', rightX, py, rightX + 5, py, grey)
local lbl = formatNumber(tk, rightYLayer.spec.yAxisFormat, rightYLayer.spec.yType == "integer")
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', rightX + 8, py + 3.8, grey, esc(lbl))
end
end
end
-- 3. 繪製底部 X 軸刻度 (Layer 1)
if primaryLayer.isOrdinalX then
local step = max(1, ceil(primaryLayer.nPoints / max(2, floor(plotW / (abs(xAngle) > 20 and 18 or 42)))))
for j = 1, primaryLayer.nPoints, step do
local px = mapLayerX(primaryLayer, j, j)
if primaryLayer.spec.xGrid then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + plotH)
end
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, padT + plotH, px, padT + plotH + 5, grey)
local lbl = tostring(primaryLayer.xArr[j] or "")
local ly = padT + plotH + 15
if xAngle ~= 0 then
local anchor = (xAngle < 0) and "end" or "start"
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-family="sans-serif" font-size="11px" fill="%s" transform="rotate(%d, %.2f, %.2f)">%s</text>',
px, ly - 3, anchor, grey, xAngle, px, ly - 3, esc(lbl))
else
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', px, ly, grey, esc(lbl))
end
end
else
for _, tk in ipairs(primaryLayer.xTicks) do
if tk >= primaryLayer.xMin - 1e-9 and tk <= primaryLayer.xMax + 1e-9 then
local px = mapLayerX(primaryLayer, tk, 1)
if primaryLayer.spec.xGrid then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + plotH)
end
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, padT + plotH, px, padT + plotH + 5, grey)
local lbl = primaryLayer.isDateX and formatDateValue(tk, primaryLayer.spec.xAxisFormat) or formatNumber(tk, primaryLayer.spec.xAxisFormat, primaryLayer.spec.xType == "integer")
local ly = padT + plotH + 15
if xAngle ~= 0 then
local anchor = (xAngle < 0) and "end" or "start"
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-family="sans-serif" font-size="11px" fill="%s" transform="rotate(%d, %.2f, %.2f)">%s</text>',
px, ly - 3, anchor, grey, xAngle, px, ly - 3, esc(lbl))
else
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="%s">%s</text>', px, ly, grey, esc(lbl))
end
end
end
end
-- 4. 依序繪製每個子圖表資料圖層
buf[#buf + 1] = '<g clip-path="url(#mc-clip)">'
for lIdx = 1, #layers do
local lyr = layers[lIdx]
local spec = lyr.spec
local baseY0 = mapLayerY(lyr, max(lyr.yMin, min(lyr.yMax, 0)))
for i = 1, lyr.nSeries do
local color = spec.colors[(i - 1) % #spec.colors + 1]
local alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 1
local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or (lyr.chartType == "area" and 1.5 or 2.5)
if lyr.chartType == "rect" then
local bandW = (plotW / max(1, lyr.nPoints)) * 0.8
local subBarW = lyr.stacked and bandW or max(1, (bandW - (lyr.nSeries - 1)) / lyr.nSeries)
local opAttr = (alpha < 1) and format(' fill-opacity="%.2f"', alpha) or ""
for j = 1, lyr.nPoints do
local y1v = lyr.stackedY1[i][j]
local y0v = lyr.stackedY0[i][j]
if y1v ~= nil then
local centerPx = mapLayerX(lyr, j, j)
local barX = lyr.stacked and (centerPx - bandW / 2) or (centerPx - bandW / 2 + (i - 1) * (subBarW + 1))
local py1 = mapLayerY(lyr, y1v)
local py0 = lyr.stacked and mapLayerY(lyr, y0v) or baseY0
local topY = min(py1, py0)
local rectH = max(1, abs(py0 - py1))
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"%s/>', barX, topY, subBarW, rectH, color, opAttr)
end
end
else
local segments = {}
local curSeg = {}
for j = 1, lyr.nPoints do
local xv = lyr.xNums[j]
local y1v = lyr.stackedY1[i][j]
local y0v = lyr.stackedY0[i][j] or 0
if xv ~= nil and y1v ~= nil then
curSeg[#curSeg + 1] = { mapLayerX(lyr, xv, j), mapLayerY(lyr, y1v), mapLayerY(lyr, y0v) }
else
if #curSeg > 0 then segments[#segments + 1] = curSeg; curSeg = {} end
end
end
if #curSeg > 0 then segments[#segments + 1] = curSeg end
for _, seg in ipairs(segments) do
local topPath = buildPathD(seg, spec.interpolate)
if lyr.chartType == "area" then
local areaAlpha = (alpha < 1) and alpha or (lyr.stacked and 0.85 or 0.55)
if lyr.stacked then
local revPts = {}
for k = #seg, 1, -1 do revPts[#revPts + 1] = { seg[k][1], seg[k][3] } end
local botPath = string.gsub(buildPathD(revPts, spec.interpolate), "^M", "L")
buf[#buf + 1] = format('<path d="%s %s Z" fill="%s" fill-opacity="%.2f" stroke="none"/>', topPath, botPath, color, areaAlpha)
else
buf[#buf + 1] = format('<path d="%s L %.2f,%.2f L %.2f,%.2f Z" fill="%s" fill-opacity="%.2f" stroke="none"/>',
topPath, seg[#seg][1], baseY0, seg[1][1], baseY0, color, areaAlpha)
end
end
if lw > 0 then
local sOp = (alpha < 1) and format(' stroke-opacity="%.2f"', alpha) or ""
buf[#buf + 1] = format('<path d="%s" fill="none" stroke="%s" stroke-width="%.2f" stroke-linecap="round" stroke-linejoin="round"%s/>',
topPath, color, lw, sOp)
end
end
end
end
end
buf[#buf + 1] = '</g>'
-- 5. 繪製座標軸主框線 (含右側第 2 Y 軸線)
if rightYLayer then
buf[#buf + 1] = format('<polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="%s" stroke-width="1.8"/>',
padL, padT, padL, padT + plotH, padL + plotW, padT + plotH, padL + plotW, padT, grey)
else
buf[#buf + 1] = format('<polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="%s" stroke-width="1.8"/>',
padL, padT, padL, padT + plotH, padL + plotW, padT + plotH, grey)
end
-- 6. 繪製標題與左/右/下軸標題
if chartTitle then
buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-family="sans-serif" font-size="14px" font-weight="bold" fill="%s">%s</text>',
padL + plotW / 2, grey, esc(chartTitle))
end
if primaryLayer.spec.xTitle then
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s">%s</text>',
padL + plotW / 2, padT + plotH + padB - 6, grey, esc(primaryLayer.spec.xTitle))
end
if primaryLayer.spec.yTitle then
local yc = padT + plotH / 2
buf[#buf + 1] = format('<text x="14" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s" transform="rotate(-90, 14, %.1f)">%s</text>',
yc, grey, yc, esc(primaryLayer.spec.yTitle))
end
if rightYLayer and rightYLayer.spec.yTitle then
local rx = padL + plotW + rightAxisW - 8
local yc = padT + plotH / 2
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s" transform="rotate(90, %.1f, %.1f)">%s</text>',
rx, yc, grey, rx, yc, esc(rightYLayer.spec.yTitle))
end
-- 7. 繪製整合圖例
if showAnyLegend and #legendEntries > 0 then
local legX = padL + plotW + rightAxisW + 6
local legY = padT + 4
if legendTitle then
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-family="sans-serif" font-size="12px" font-weight="bold" fill="%s">%s</text>',
legX, legY + 10, grey, esc(legendTitle))
legY = legY + 20
end
for idx, ent in ipairs(legendEntries) do
local iy = legY + (idx - 1) * 18
if ent.chartType == "line" then
buf[#buf + 1] = format('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="2.5"/>', legX, iy + 5.5, legX + 14, iy + 5.5, ent.color)
else
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s"/>', legX, iy, ent.color)
end
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-family="sans-serif" font-size="11px" fill="%s">%s</text>',
legX + 18, iy + 9.5, grey, esc(ent.title))
end
end
TrackingCategory.append('有SVG圖表的頁面')
return mw.svg.new()
:setAttribute('viewBox', format('0 0 %d %d', ceil(totalW), ceil(totalH)))
:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
:setImgAttribute('width', tostring(ceil(totalW)))
:setImgAttribute('height', tostring(ceil(totalH)))
:setImgAttribute('class', 'graph-multichart-svg')
:setContent(table.concat(buf))
:toImage()
end
return p