模組:Graph/SVGRenderer
外观
-- Module:Graph/SVGRenderer
local p = {}
local TrackingCategory = require('Module:TrackingCategory')
local Utils = require('Module:Graph/RenderUtils')
local format, sub, gsub, match = string.format, string.sub, string.gsub, string.match
local floor, ceil, abs, max, min = math.floor, math.ceil, math.abs, math.max, math.min
local sin, cos, sqrt, pi = math.sin, math.cos, math.sqrt, math.pi
local esc = Utils.esc
local formatNumber = Utils.formatNumber
local parseDateValue = Utils.parseDateValue
local formatDateValue = Utils.formatDateValue
local applyScale = Utils.applyScale
local computeNiceTicks = Utils.computeNiceTicks
local buildPathD = Utils.buildPathD
local renderSymbol = Utils.renderSymbol
local finalizeSvgWithOverlay = Utils.finalizeSvgWithOverlay
local buildPointHit = Utils.buildPointHit
local buildRectHit = Utils.buildRectHit
local FlushImgOverlays = Utils.FlushImgOverlays
-- 匯出共用介面供 Module:Graph/MapRenderer 等外部模組呼叫
p.renderSymbol = renderSymbol
p.finalizeSvgWithOverlay = finalizeSvgWithOverlay
-- 短路直接輸出原生 <svg> 字串
function p.RenderSVG(frame, kvargs)
local cargs = type(frame)==type({}) and (frame[1]or frame.content or frame.code) and frame or nil
local call_by_module = not(not(cargs or not (type(frame)==type({}) and frame.getParent and type(frame.getParent)==type(function()end))))
local raise = function(msg) if call_by_module then error(msg) else return "<strong class='error'>"..msg.."</strong>" end end
local rargs = type(frame)==type({}) and frame.args or (type(cargs)==type({}) and cargs) or {}
if type(frame)==type("string") then rargs = { [1] = frame } end --從其他模組呼叫
local parent = type(frame)==type({}) and frame.getParent and type(frame.getParent)==type(function()end) and frame:getParent() or {}
local pargs = type(parent)==type({}) and parent.args or (type(kvargs)==type({}) and kvargs) or {}
local args = {}
for rk, rv in pairs(rargs) do args[rk] = rv end
-- 支援從母模板傳遞參數
for pk, pv in pairs(pargs) do args[pk] = pv or rargs[pk] or args[pk] end
-- 取得 SVG 原始碼 (支援使用命名的 code= 或是匿名參數 1=)
local raw = args and (args[1] or args.content or args.code) or ''
local svg_code = mw.text.unstripNoWiki(tostring(raw))
if not svg_code or mw.text.trim(svg_code) == '' then
return raise("請提供 SVG 原始碼 (code 參數)")
end
-- 1. 解析 SVG 結構
-- 利用貪婪匹配 (.*) 確保能一路抓到最後一個 </svg>,完美支援內部嵌套的 <svg>
-- 由於 SVG 原始碼都為 ASCII 結構,使用原生 string.match 效能最高
local open_tag_content, inner_html = string.match(svg_code, "<[sS][vV][gG]%s*([^>]*)>(.*)</[sS][vV][gG]>")
if not inner_html or mw.text.trim(inner_html) == '' then
return raise("請提供 SVG 原始碼 (code 參數)")
end
if not open_tag_content then
return raise("無法解析 SVG 格式,請確認原始碼包含完整的 <svg> 與 </svg> 標籤。")
end
-- 2. 實例化 mw.svg
local svg_obj = mw.svg.new()
-- 3. 自動提取並套用 <svg> 的屬性
-- 匹配模式說明:([%w%-%:]+) 匹配屬性名,(["\']) 匹配引號,(.-) 非貪婪匹配內容
for key, quote, value in string.gmatch(open_tag_content, '([%w%-%:]+)%s*=%s*(["\'])(.-)%2') do
local lower_key = string.lower(key)
-- 自動過濾掉 javascript: 以及 onload= 等事件處理,確保符合維基百科安全規範
if not string.match(lower_key, "^on") then
svg_obj:setAttribute(key, value)
end
end
-- 4. 填入內部 DOM
svg_obj:setContent(inner_html)
-- 5. 設定維基百科輸出的 <img> 標籤屬性
svg_obj:setImgAttribute('width', '100%')
svg_obj:setImgAttribute('height', '100%') -- 讓高度也能跟隨外部容器自適應
if args.img_class then svg_obj:setImgAttribute('class', args.img_class) end
if args.alt then svg_obj:setImgAttribute('alt', args.alt) end
-- 產生 SVG Strip Marker
local img_marker = svg_obj:toImage()
-- 6. 建立外部 HTML 包裝容器
-- 讓用戶可透過 wiki 代碼的 CSS 控制圖片大小與排版
local wrapper_tag = (args.inline == 'yes') and 'span' or 'div'
local wrapper = mw.html.create(wrapper_tag)
if args.inline == 'yes' then
wrapper:css('display', 'inline-block')
end
if args.class then wrapper:addClass(args.class) end
if args.style then wrapper:cssText(args.style) end
if args.width then wrapper:css('width', args.width) end
if args.height then wrapper:css('height', args.height) end
-- 支援簡易的對齊參數
if args.align == 'center' then
wrapper:css('margin', '0 auto'):css('display', 'block')
elseif args.align == 'left' then
wrapper:css('float', 'left')
elseif args.align == 'right' then
wrapper:css('float', 'right')
end
-- 置入輸出的圖片
wrapper:wikitext(img_marker)
return tostring(wrapper)
end
-- 3D 空間圖表渲染器 (支援 bar3d, scatter3d, surface3d, line3d;按需延遲載入 MathEngine)
local function render3DChart(spec)
local MathEngine = require('Module:Graph/MathEngine')
local plotW = max(180, spec.width or 360)
local plotH = max(160, spec.height or 260)
local chartType = spec.chartType or "scatter3d"
local grey = spec.persistentGrey or "#54595d"
local xArr = spec.x or {}
local ySeries = spec.y or {}
local zSeries = spec.z or {}
local nSeries = max(1, #ySeries)
local nPoints = xArr.n or #xArr
for i = 1, nSeries do
local len = ySeries[i] and (ySeries[i].n or #ySeries[i]) or 0
if len > nPoints then nPoints = len end
end
local hasExplicitZ = (type(zSeries) == "table" and #zSeries > 0)
-- 1. 解析 X 軸 (支援數值或類別)
local isOrdinalX = (spec.xType == "string")
local xNums = {}
local dataXMin, dataXMax = math.huge, -math.huge
for j = 1, nPoints do
local xv = not isOrdinalX and tonumber(xArr[j]) or nil
if xv == nil and xArr[j] ~= nil and xArr[j] ~= false then
isOrdinalX = true
end
xNums[j] = xv
if xv then
if xv < dataXMin then dataXMin = xv end
if xv > dataXMax then dataXMax = xv end
end
end
if isOrdinalX then
for j = 1, nPoints do xNums[j] = j end
dataXMin, dataXMax = 1, max(1, nPoints)
elseif dataXMin == math.huge then
dataXMin, dataXMax = 0, max(1, nPoints)
end
-- 2. 解析 Y 軸與垂直高度 Z 軸
local isOrdinalY = not hasExplicitZ
local dataYMin, dataYMax = math.huge, -math.huge
local dataZMin, dataZMax = math.huge, -math.huge
if chartType == "bar3d" then dataZMin = 0 end
if hasExplicitZ then
for i = 1, nSeries do
local yArr = ySeries[i] or {}
local zArr = zSeries[i] or zSeries[1] or {}
for j = 1, nPoints do
local yv = tonumber(yArr[j])
local zv = tonumber(zArr[j])
if yv then
if yv < dataYMin then dataYMin = yv end
if yv > dataYMax then dataYMax = yv end
end
if zv then
if zv < dataZMin then dataZMin = zv end
if zv > dataZMax then dataZMax = zv end
end
end
end
if dataYMin == math.huge then
isOrdinalY = true
dataYMin, dataYMax = 1, max(1, nSeries)
end
else
dataYMin, dataYMax = 1, max(1, nSeries)
for i = 1, nSeries do
local yArr = ySeries[i] or {}
for j = 1, nPoints do
local zv = tonumber(yArr[j])
if zv then
if zv < dataZMin then dataZMin = zv end
if zv > dataZMax then dataZMax = zv end
end
end
end
end
if dataZMin == math.huge then dataZMin, dataZMax = 0, 10 end
-- 3. 計算三軸刻度與正規化範圍 [-1, 1]
local isPctX = spec.xAxisFormat and (string.find(spec.xAxisFormat, "%", 1, true) ~= nil or string.find(spec.xAxisFormat, "‰", 1, true) ~= nil)
local isPctY = spec.yAxisFormat and (string.find(spec.yAxisFormat, "%", 1, true) ~= nil or string.find(spec.yAxisFormat, "‰", 1, true) ~= nil)
local xTicks, xMin, xMax = {}, tonumber(spec.xMin) or dataXMin, tonumber(spec.xMax) or dataXMax
if not isOrdinalX then
local niceMin, niceMax
xTicks, niceMin, niceMax = computeNiceTicks(xMin, xMax, 4, spec.xScaleType, (spec.xType == "integer") and not isPctX)
if not spec.xMin then xMin = niceMin end
if not spec.xMax then xMax = niceMax end
else
xMin, xMax = 0.5, max(1, nPoints) + 0.5
end
if xMax <= xMin then xMax = xMin + 1 end
local yTicks, yMin, yMax = {}, tonumber(spec.yMin) or dataYMin, tonumber(spec.yMax) or dataYMax
if not isOrdinalY then
local niceMin, niceMax
yTicks, niceMin, niceMax = computeNiceTicks(yMin, yMax, 4, spec.yScaleType, (spec.yType == "integer") and not isPctY)
if not spec.yMin then yMin = niceMin end
if not spec.yMax then yMax = niceMax end
else
yMin, yMax = 0.5, max(1, nSeries) + 0.5
end
if yMax <= yMin then yMax = yMin + 1 end
local zMin = tonumber(spec.zMin) or (not hasExplicitZ and tonumber(spec.yMin)) or dataZMin
local zMax = tonumber(spec.zMax) or (not hasExplicitZ and tonumber(spec.yMax)) or dataZMax
local zScaleType = spec.zScaleType or (not hasExplicitZ and spec.yScaleType) or "linear"
local zFormat = spec.zAxisFormat or (not hasExplicitZ and spec.yAxisFormat)
local isPctZ = zFormat and (string.find(zFormat, "%", 1, true) ~= nil or string.find(zFormat, "‰", 1, true) ~= nil)
local zIsInt = ((spec.zType == "integer") or (not hasExplicitZ and spec.yType == "integer")) and not isPctZ
local zTicks, niceZMin, niceZMax = computeNiceTicks(zMin, zMax, 4, zScaleType, zIsInt)
if not spec.zMin and not (not hasExplicitZ and spec.yMin) then zMin = niceZMin end
if not spec.zMax and not (not hasExplicitZ and spec.yMax) then zMax = niceZMax end
if zMax <= zMin then zMax = zMin + 1 end
if isPctZ and (dataZMin < -1e-9 or dataZMax > 1 + 1e-9 or zMin < -1e-9 or zMax > 1 + 1e-9) then
for _, bVal in ipairs({ 0, 1 }) do
if bVal >= zMin - 1e-9 and bVal <= zMax + 1e-9 then
local exists = false
for _, tk in ipairs(zTicks) do
if abs(tk - bVal) < 1e-6 then exists = true; break end
end
if not exists then zTicks[#zTicks + 1] = bVal end
end
end
table.sort(zTicks)
end
local function normX(v) return 2 * applyScale(v, xMin, xMax, spec.xScaleType) - 1 end
local function normY(v) return 2 * applyScale(v, yMin, yMax, spec.yScaleType) - 1 end
local function normZ(v) return 2 * applyScale(v, zMin, zMax, zScaleType) - 1 end
local xTickNorms, yTickNorms, zTickNorms = {}, {}, {}
if isOrdinalX then
local step = max(1, ceil(nPoints / 6))
for j = 1, nPoints, step do
xTickNorms[#xTickNorms + 1] = { norm = normX(j), label = tostring(xArr[j] or j) }
end
else
for _, tk in ipairs(xTicks) do
if tk >= xMin - 1e-9 and tk <= xMax + 1e-9 then
xTickNorms[#xTickNorms + 1] = { norm = normX(tk), label = formatNumber(tk, spec.xAxisFormat, spec.xType == "integer") }
end
end
end
if isOrdinalY then
local step = max(1, ceil(nSeries / 6))
for i = 1, nSeries, step do
yTickNorms[#yTickNorms + 1] = { norm = normY(i), label = tostring(spec.seriesTitles[i] or ("S" .. i)) }
end
else
for _, tk in ipairs(yTicks) do
if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
yTickNorms[#yTickNorms + 1] = { norm = normY(tk), label = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer") }
end
end
end
for _, tk in ipairs(zTicks) do
if tk >= zMin - 1e-9 and tk <= zMax + 1e-9 then
zTickNorms[#zTickNorms + 1] = { norm = normZ(tk), label = formatNumber(tk, zFormat, zIsInt) }
end
end
-- 4. 配置畫布邊距與自動校準 3D 透視相機
local legW = 0
if spec.showLegend and nSeries > 0 then
for i = 1, nSeries do
local t = spec.seriesTitles[i] or ("y" .. i)
legW = max(legW, mw.ustring.len(t) * 7.5 + 34)
end
legW = min(200, max(80, legW))
end
local padL, padR = 38, 28 + legW
local padT = (spec.title and 32 or 16)
local padB = 28
local totalW = padL + plotW + padR
local totalH = padT + plotH + padB
local cam = MathEngine.createCamera({
theta = spec.viewTheta or 45,
phi = spec.viewPhi or 28,
distance = spec.viewDistance or 5.5
})
cam.fitToViewport(padL + 12, padT + 10, plotW - 24, plotH - 20)
local stage = MathEngine.buildBoxStage(cam, xTickNorms, yTickNorms, zTickNorms)
local customState = { defs = {}, map = {}, count = 0 }
local buf = { "" }
-- 5. 繪製開放式底板網格、外伸刻度線與三軸主框線
if not spec.hideAxes then
for _, g in ipairs(stage.grids) do
buf[#buf + 1] = format('<line class="g3-grid" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>', g.x1, g.y1, g.x2, g.y2)
end
for _, tk in ipairs(stage.tickLines) do
buf[#buf + 1] = format('<line class="g3-tick" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>', tk.x1, tk.y1, tk.x2, tk.y2)
end
for _, ax in ipairs(stage.axesLines) do
buf[#buf + 1] = format('<line class="g3-axis" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>', ax.x1, ax.y1, ax.x2, ax.y2)
end
for _, a in ipairs(stage.xAnchors) do
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', a.sx, a.sy, grey, esc(a.label))
end
for _, a in ipairs(stage.yAnchors) do
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', a.sx, a.sy, grey, esc(a.label))
end
for _, a in ipairs(stage.zAnchors) do
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-size="11px" fill="%s">%s</text>', a.sx, a.sy, grey, esc(a.label))
end
local xTitleStr = spec.xTitle or "x"
local yTitleStr = spec.yTitle or (hasExplicitZ and (spec.yTitle or "y") or "y")
local zTitleStr = spec.zTitle or (not hasExplicitZ and spec.yTitle) or "z"
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
stage.titleAnchors.x.sx, stage.titleAnchors.x.sy, grey, esc(xTitleStr))
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
stage.titleAnchors.y.sx, stage.titleAnchors.y.sy, grey, esc(yTitleStr))
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
stage.titleAnchors.z.sx, stage.titleAnchors.z.sy, grey, esc(zTitleStr))
end
-- 6. 收集 3D 幾何圖元並進行畫家演算法 (Z-Sorting)
local drawList = {}
local valTextBuf = {}
local tipBuf = {}
local enableTips = (spec.interactive ~= false) and (nSeries * nPoints <= 250)
local baseZVal = max(zMin, min(zMax, 0))
local wzBase = normZ(baseZVal)
local orderCounter = 0
if chartType == "bar3d" then
local dx = min(0.22, (1.6 / max(1, nPoints)) * 0.36)
local dy = min(0.22, (1.6 / max(1, nSeries)) * 0.36)
for i = 1, nSeries do
local c = spec.colors[(i - 1) % #spec.colors + 1]
local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
local alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 0.95
local yArr = ySeries[i] or {}
local zArr = hasExplicitZ and (zSeries[i] or zSeries[1] or {}) or yArr
local sTitle = spec.seriesTitles[i] or ("S" .. i)
for j = 1, nPoints do
local xv = xNums[j]
local yv = hasExplicitZ and tonumber(yArr[j]) or i
local zv = tonumber(zArr[j])
if xv and yv and zv then
orderCounter = orderCounter + 1
local wx, wy, wz = normX(xv), normY(yv), normZ(zv)
local col = hasSeriesCmap and MathEngine.sampleColormap((wz + 1) * 0.5, sCmap) or baseCol
local faces, topPt = MathEngine.buildCuboidFaces(cam, wx - dx, wx + dx, wy - dy, wy + dy, wzBase, wz, col, alpha, orderCounter)
for f = 1, #faces do drawList[#drawList + 1] = faces[f] end
local zStr = formatNumber(zv, zFormat, zIsInt)
if spec.showValues then
local sv = spec.showValues
local fSize = tonumber(sv.fontsize) or 10
local fColor = sv.fontcolor or grey
valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" font-weight="bold" fill="%s" data-halo="1">%s</text>',
topPt.sx, topPt.sy - 5, fSize, fColor, esc(zStr))
end
if enableTips then
local xLbl = isOrdinalX and tostring(xArr[j] or j) or formatNumber(xv, spec.xAxisFormat, spec.xType == "integer")
local yLbl = isOrdinalY and sTitle or formatNumber(yv, spec.yAxisFormat, spec.yType == "integer")
local tipHtml = format('<b>%s</b> (%s, %s): %s', esc(sTitle), esc(xLbl), esc(yLbl), esc(zStr))
tipBuf[#tipBuf + 1] = buildPointHit(topPt.sx, topPt.sy, totalW, totalH, tipHtml, col, 16)
end
end
end
end
elseif chartType == "surface3d" then
-- 建構 (X, Y) 曲面網格並利用雙向切向量法向量與科學色階著色
local grid = {}
local rCount, cCount = nSeries, nPoints
if hasExplicitZ and nSeries == 1 then
-- 若為單一數列 (x, y, z) 點集,自動將不重複 X 與 Y 歸納為二維網格
local uxSet, uySet, uxList, uyList = {}, {}, {}, {}
local yArr, zArr = ySeries[1] or {}, zSeries[1] or {}
for j = 1, nPoints do
local xv, yv, zv = xNums[j], tonumber(yArr[j]), tonumber(zArr[j])
if xv and yv and zv then
if not uxSet[xv] then uxSet[xv] = true; uxList[#uxList + 1] = xv end
if not uySet[yv] then uySet[yv] = true; uyList[#uyList + 1] = yv end
end
end
table.sort(uxList); table.sort(uyList)
local xIdxMap, yIdxMap = {}, {}
for idx, v in ipairs(uxList) do xIdxMap[v] = idx end
for idx, v in ipairs(uyList) do yIdxMap[v] = idx end
rCount, cCount = #uyList, #uxList
for r = 1, rCount do grid[r] = {} end
for j = 1, nPoints do
local xv, yv, zv = xNums[j], tonumber(yArr[j]), tonumber(zArr[j])
if xv and yv and zv and xIdxMap[xv] and yIdxMap[yv] then
grid[yIdxMap[yv]][xIdxMap[xv]] = { normX(xv), normY(yv), normZ(zv), zv, xv, yv }
end
end
else
for i = 1, nSeries do
grid[i] = {}
local yArr = ySeries[i] or {}
local zArr = hasExplicitZ and (zSeries[i] or zSeries[1] or {}) or yArr
for j = 1, nPoints do
local xv = xNums[j]
local yv = hasExplicitZ and tonumber(yArr[j]) or i
local zv = tonumber(zArr[j])
if xv and yv and zv then
grid[i][j] = { normX(xv), normY(yv), normZ(zv), zv, xv, yv }
end
end
end
end
local cmapName = spec.colorMap or "viridis"
for r = 1, rCount - 1 do
for c = 1, cCount - 1 do
local g1 = grid[r] and grid[r][c]
local g2 = grid[r] and grid[r][c + 1]
local g3 = grid[r + 1] and grid[r + 1][c + 1]
local g4 = grid[r + 1] and grid[r + 1][c]
if g1 and g2 and g3 and g4 then
orderCounter = orderCounter + 1
local avgNormZ = (g1[3] + g2[3] + g3[3] + g4[3]) * 0.25
local tCol = (avgNormZ + 1) * 0.5
local baseCol = MathEngine.sampleColormap(tCol, cmapName)
local qFace = MathEngine.buildQuadFace(cam, g1, g2, g3, g4, baseCol, 0.94, orderCounter)
drawList[#drawList + 1] = qFace
if enableTips and (#tipBuf < 180) then
local avgRawZ = (g1[4] + g2[4] + g3[4] + g4[4]) * 0.25
local tipHtml = format('<b>z</b>: %s', esc(formatNumber(avgRawZ, zFormat, zIsInt)))
tipBuf[#tipBuf + 1] = buildPointHit(qFace.cx, qFace.cy, totalW, totalH, tipHtml, qFace.fill, 12)
end
end
end
end
else
-- scatter3d 與 line3d
local sizeMin, sizeMax = math.huge, -math.huge
if spec.sizes then
for i = 1, nSeries do
local sArr = spec.sizes[i]
if sArr then
for j = 1, nPoints do
local sv = tonumber(sArr[j])
if sv then
if sv < sizeMin then sizeMin = sv end
if sv > sizeMax then sizeMax = sv end
end
end
end
end
end
for i = 1, nSeries do
local c = spec.colors[(i - 1) % #spec.colors + 1]
local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
local alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 0.9
local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or 2.0
local yArr = ySeries[i] or {}
local zArr = hasExplicitZ and (zSeries[i] or zSeries[1] or {}) or yArr
local sTitle = spec.seriesTitles[i] or ("y" .. i)
local prevPt = nil
for j = 1, nPoints do
local xv = xNums[j]
local yv = hasExplicitZ and tonumber(yArr[j]) or i
local zv = tonumber(zArr[j])
if xv and yv and zv then
orderCounter = orderCounter + 1
local wx, wy, wz = normX(xv), normY(yv), normZ(zv)
local pt = cam.project(wx, wy, wz)
local floorPt = cam.project(wx, wy, -1)
local col = hasSeriesCmap and MathEngine.sampleColormap((wz + 1) * 0.5, sCmap) or baseCol
if chartType == "line3d" and prevPt and lw > 0 then
drawList[#drawList + 1] = {
kind = "line",
depth = (prevPt.depth + pt.depth) * 0.5,
order = orderCounter,
x1 = prevPt.sx, y1 = prevPt.sy, x2 = pt.sx, y2 = pt.sy,
stroke = col, strokeWidth = lw, alpha = alpha
}
else
-- 繪製散佈點至底板的投影輔助垂線,幫助讀者判讀三維空間位置
drawList[#drawList + 1] = {
kind = "stem",
depth = pt.depth - 0.02,
order = orderCounter,
x1 = floorPt.sx, y1 = floorPt.sy, x2 = pt.sx, y2 = pt.sy,
stroke = col
}
end
prevPt = pt
local dynSize = spec.sizes and ((spec.sizes[i] and tonumber(spec.sizes[i][j])) or (spec.sizes[1] and tonumber(spec.sizes[1][j]))) or nil
local userR = (spec.symbols and (spec.symbols.radii[i] or spec.symbols.radii[1])) or (chartType == "line3d" and 3.0 or 4.5)
local baseR = userR
if dynSize then
local scaleMul = userR / 4.5
if sizeMax > sizeMin then
baseR = (3.5 + sqrt(max(0, (dynSize - sizeMin) / (sizeMax - sizeMin))) * 11.5) * scaleMul
else
baseR = max(userR, sqrt(max(1, abs(dynSize))) * 0.75 * scaleMul)
end
end
local rProj = max(2.0, baseR * pt.pScale)
local shp = (spec.symbols and (spec.symbols.shapes[i] or spec.symbols.shapes[1])) or "circle"
local fillCol = (spec.symbols and spec.symbols.noFill) and "none" or MathEngine.shadeColor(col, 1.06)
local strokeCol = MathEngine.shadeColor(col, 0.72)
if chartType == "scatter3d" or (spec.symbols and spec.symbols.enabled) or dynSize then
drawList[#drawList + 1] = {
kind = "sym",
depth = pt.depth,
order = orderCounter + 0.5,
shape = shp, cx = pt.sx, cy = pt.sy, r = rProj,
fill = fillCol, stroke = strokeCol, strokeW = 1.0, alpha = alpha
}
end
local zStr = formatNumber(zv, zFormat, zIsInt)
if spec.showValues then
local sv = spec.showValues
local fSize = tonumber(sv.fontsize) or 10
local fColor = sv.fontcolor or grey
valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" fill="%s" data-halo="1">%s</text>',
pt.sx, pt.sy - rProj - 4, fSize, fColor, esc(zStr))
end
if enableTips then
local xLbl = isOrdinalX and tostring(xArr[j] or j) or formatNumber(xv, spec.xAxisFormat, spec.xType == "integer")
local yLbl = isOrdinalY and sTitle or formatNumber(yv, spec.yAxisFormat, spec.yType == "integer")
local tipHtml = format('<b>%s</b> (%s, %s, %s)', esc(sTitle), esc(xLbl), esc(yLbl), esc(zStr))
local swSpec = { shape = shp, fill = fillCol, stroke = strokeCol, strokeWidth = 1.0, r = rProj }
tipBuf[#tipBuf + 1] = buildPointHit(pt.sx, pt.sy, totalW, totalH, tipHtml, col, max(14, rProj * 2 + 4), swSpec)
end
else
prevPt = nil
end
end
end
end
-- 7. 依深度由遠到近排序並輸出 SVG 圖元
MathEngine.depthSort(drawList)
for _, item in ipairs(drawList) do
if item.kind == "poly" then
local opAttr = (item.alpha and item.alpha < 1) and format(' fill-opacity="%.2f"', item.alpha) or ""
buf[#buf + 1] = format('<polygon points="%s" fill="%s" stroke="%s" stroke-width="%.2f" stroke-linejoin="round"%s/>',
item.points, item.fill, item.stroke, item.strokeWidth or 0.6, opAttr)
elseif item.kind == "line" then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f" stroke-linecap="round"/>',
item.x1, item.y1, item.x2, item.y2, item.stroke, item.strokeWidth)
elseif item.kind == "stem" then
buf[#buf + 1] = format('<line class="g3-stem" x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s"/>',
item.x1, item.y1, item.x2, item.y2, item.stroke)
elseif item.kind == "sym" then
buf[#buf + 1] = renderSymbol(item.shape, item.cx, item.cy, item.r, item.fill, item.stroke, item.strokeW, item.alpha, customState)
end
end
for k = 1, #valTextBuf do buf[#buf + 1] = valTextBuf[k] end
if spec.title then
buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>',
padL + plotW / 2, grey, esc(spec.title))
end
if spec.showLegend and nSeries > 0 then
local legX = padL + plotW + 6
local legY = padT + 8
if spec.legendTitle then
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="12px" font-weight="bold" fill="%s">%s</text>', legX, legY + 10, grey, esc(spec.legendTitle))
legY = legY + 20
end
for i = 1, nSeries do
local c = spec.colors[(i - 1) % #spec.colors + 1]
local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
local iy = legY + (i - 1) * 18
if hasSeriesCmap then
local gradId = "gr-3d-leg-cmap-" .. i
customState.defs[#customState.defs + 1] = Utils.buildColormapGradientDef(gradId, sCmap, "0%", "0%", "100%", "0%")
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="16" height="5" rx="1" fill="url(#%s)"/>', legX, iy + 3, gradId)
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>', legX + 20, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
else
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s"/>', legX, iy, baseCol)
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>', legX + 18, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
end
end
end
local svgStyle = '<style>.g3-grid{stroke:#e1e3e5;stroke-width:0.85}.g3-tick{stroke:#333;stroke-width:1.1}.g3-axis{stroke:#222;stroke-width:1.4;stroke-linecap:round}.g3-stem{stroke-width:0.85;stroke-dasharray:2,2;opacity:0.55}</style>'
buf[1] = format('<defs>%s%s</defs>', svgStyle, table.concat(customState.defs))
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-fade" or nil
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', customState.count > 0, table.concat(tipBuf), animCls)
end
-- 直角座標系與極座標圖表主渲染器
function p.renderChart(spec)
if spec.is3D or spec.chartType == "scatter3d" or spec.chartType == "bar3d" or spec.chartType == "surface3d" or spec.chartType == "line3d" then
return render3DChart(spec)
end
local renderer = require('Module:Graph/ChartRenderer')
if spec.rangedRect then return renderer.renderRangedRectChart(spec) end
if spec.boxPlot or spec.chartType == "boxplot" then return renderer.renderBoxPlotChart(spec) end
if spec.gantt or spec.chartType == "gantt" then return renderer.renderGanttChart(spec) end
if spec.heatmap or spec.chartType == "heatmap" then return renderer.renderHeatmapChart(spec) end
if spec.chartType == "polarbar" then return renderer.renderPolarBarChart(spec) end
if spec.chartType == "radar" then return renderer.renderRadarChart(spec) end
if spec.chartType == "pie" then return renderer.renderPieChart(spec) end
local plotW = max(60, spec.width or 200)
local plotH = max(50, spec.height or 200)
local chartType = spec.chartType or "line"
local stacked = spec.stacked
local hideAxes = spec.hideAxes
local xArr = spec.x or {}
local ySeries = spec.y or {}
local nSeries = #ySeries
local nPoints = xArr.n or #xArr
for i = 1, nSeries do
local len = ySeries[i].n or #ySeries[i]
if len > nPoints then nPoints = len end
end
local zSeries = spec.z or {}
local hasRectZ = (chartType == "rect") and (type(zSeries) == "table" and #zSeries > 0)
local isContinuousRect = (chartType == "rect") and (hasRectZ or spec.isHist) and (spec.xType ~= "string")
local isDateX = (spec.xType == "date")
local isOrdinalX = ((chartType == "rect") and not isContinuousRect) or (spec.xType == "string")
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)
else
xv = tonumber(rawX)
if not xv and rawX ~= nil and rawX ~= false then
local dv = parseDateValue(rawX)
if dv then
isDateX = true
xv = dv
else
isOrdinalX = true
isContinuousRect = false
xv = j
end
end
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
-- 若為直角座標不等寬直方圖 / 連續直方圖,預先計算每一筆資料的 [x0, x1] 物理區間並擴展 X 軸上下界
local rectDefaultW = 1
local isEdgeRect = (spec.barAlign ~= "center")
if isContinuousRect then
if not hasRectZ and nPoints > 1 then
local sortedX = {}
for j = 1, nPoints do
if xNums[j] then sortedX[#sortedX + 1] = xNums[j] end
end
table.sort(sortedX)
local minDiff = math.huge
for k = 2, #sortedX do
local d = sortedX[k] - sortedX[k - 1]
if d > 1e-6 and d < minDiff then minDiff = d end
end
if minDiff < math.huge then rectDefaultW = minDiff end
end
for j = 1, nPoints do
local xv = xNums[j]
if xv then
for i = 1, max(1, nSeries) do
local zw = hasRectZ and ((zSeries[i] and tonumber(zSeries[i][j])) or (zSeries[1] and tonumber(zSeries[1][j]))) or rectDefaultW
zw = abs(zw or rectDefaultW)
local x0 = isEdgeRect and xv or (xv - zw * 0.5)
local x1 = isEdgeRect and (xv + zw) or (xv + zw * 0.5)
if x0 < dataXMin then dataXMin = x0 end
if x1 > dataXMax then dataXMax = x1 end
end
end
end
end
local stackedY0, stackedY1 = {}, {}
local dataYMin, dataYMax = math.huge, -math.huge
if stacked or chartType == "rect" or chartType == "area" then
dataYMin = 0
end
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 rawV = ySeries[i][j]
local v = (rawV ~= false and rawV ~= nil) and tonumber(rawV) or nil
if v ~= nil then
if stacked then
if v >= 0 then
stackedY0[i][j] = posSum
posSum = posSum + v
stackedY1[i][j] = posSum
else
stackedY0[i][j] = negSum
negSum = negSum + v
stackedY1[i][j] = negSum
end
else
stackedY0[i][j] = 0
stackedY1[i][j] = v
end
local yLo = stackedY0[i][j]
local yHi = stackedY1[i][j]
if yLo < dataYMin then dataYMin = yLo end
if yHi < dataYMin then dataYMin = yHi end
if yLo > dataYMax then dataYMax = yLo end
if yHi > dataYMax then dataYMax = yHi end
end
end
end
if dataYMin == math.huge then dataYMin, dataYMax = 0, 10 end
if spec.yScaleType == "log" and dataYMin <= 0 then dataYMin = 1 end
local reqXMin = spec.xMin and (isDateX and parseDateValue(spec.xMin) or tonumber(spec.xMin)) or nil
local reqXMax = spec.xMax and (isDateX and parseDateValue(spec.xMax) or tonumber(spec.xMax)) or nil
local xMin = reqXMin or dataXMin
local xMax = reqXMax or dataXMax
local yMin = tonumber(spec.yMin) or dataYMin
local yMax = tonumber(spec.yMax) or dataYMax
local isPctX = spec.xAxisFormat and (string.find(spec.xAxisFormat, "%", 1, true) ~= nil or string.find(spec.xAxisFormat, "‰", 1, true) ~= nil)
local isPctY = spec.yAxisFormat and (string.find(spec.yAxisFormat, "%", 1, true) ~= nil or string.find(spec.yAxisFormat, "‰", 1, true) ~= nil)
local needPctBoundsY = isPctY and (dataYMin < -1e-9 or dataYMax > 1 + 1e-9 or yMin < -1e-9 or yMax > 1 + 1e-9)
local needPctBoundsX = isPctX and (not isOrdinalX) and (not isDateX) and (dataXMin < -1e-9 or dataXMax > 1 + 1e-9 or xMin < -1e-9 or xMax > 1 + 1e-9)
local xTicks, yTicks = {}, {}
local gridRadius = nil
if spec.isScatterGrid then
xMin = dataXMin - 0.5
xMax = dataXMax + 0.5
yMin = dataYMin - 0.5
yMax = dataYMax + 0.5
gridRadius = max(plotW / max(1, xMax - xMin), plotH / max(1, yMax - yMin)) * 0.505
else
local niceYMin, niceYMax
yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, max(3, floor(plotH / 36)), spec.yScaleType, (spec.yType == "integer") and not isPctY)
if not spec.yMin then yMin = niceYMin end
if not spec.yMax then yMax = niceYMax end
if needPctBoundsY then
for _, bVal in ipairs({ 0, 1 }) do
if bVal >= yMin - 1e-9 and bVal <= yMax + 1e-9 then
local exists = false
for _, tk in ipairs(yTicks) do
if abs(tk - bVal) < 1e-6 then exists = true; break end
end
if not exists then yTicks[#yTicks + 1] = bVal end
end
end
table.sort(yTicks)
end
if not isOrdinalX then
local niceXMin, niceXMax
xTicks, niceXMin, niceXMax = computeNiceTicks(xMin, xMax, max(3, floor(plotW / 65)), spec.xScaleType, (spec.xType == "integer" or isDateX) and not isPctX)
if not reqXMin and not isDateX then xMin = niceXMin end
if not reqXMax and not isDateX then xMax = niceXMax end
if needPctBoundsX then
for _, bVal in ipairs({ 0, 1 }) do
if bVal >= xMin - 1e-9 and bVal <= xMax + 1e-9 then
local exists = false
for _, tk in ipairs(xTicks) do
if abs(tk - bVal) < 1e-6 then exists = true; break end
end
if not exists then xTicks[#xTicks + 1] = bVal end
end
end
table.sort(xTicks)
end
end
end
if xMax <= xMin then xMax = xMin + 1 end
if yMax <= yMin then yMax = yMin + 1 end
local xAngle = tonumber(spec.xAxisAngle) or 0
local padL, padR, padT, padB = 0, 0, 0, 0
if not hideAxes then
local maxYLblLen = 3
for _, tk in ipairs(yTicks) do
local s = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer")
maxYLblLen = max(maxYLblLen, mw.ustring.len(s))
end
padL = max(36, maxYLblLen * 7 + 14) + (spec.yTitle and 18 or 0)
padT = (spec.title and 30 or 12)
local extraAngleH = (abs(xAngle) > 15) and 24 or 0
padB = 26 + extraAngleH + (spec.xTitle and 18 or 0)
padR = 16
elseif spec.isScatterGrid then
local qz = max(8, floor(min(plotW, plotH) * 0.06))
padL, padR, padT, padB = qz, qz, qz + (spec.title and 24 or 0), qz
end
if spec.showLegend and nSeries > 0 then
local legW = 70
for i = 1, nSeries do
local t = spec.seriesTitles[i] or ("y" .. i)
legW = max(legW, mw.ustring.len(t) * 7.5 + 34)
end
if spec.legendTitle then
legW = max(legW, mw.ustring.len(spec.legendTitle) * 8 + 20)
end
padR = padR + min(220, legW)
end
local totalW = padL + plotW + padR
local totalH = max(padT + plotH + padB, padT + (spec.showLegend and (nSeries * 18 + 28) or 0) + padB)
local function mapX(val, idx)
if isOrdinalX then
if chartType == "rect" then
return padL + ((idx - 0.5) / max(1, nPoints)) * plotW
end
if nPoints <= 1 then return padL + plotW / 2 end
return padL + ((idx - 1) / (nPoints - 1)) * plotW
end
return padL + applyScale(val, xMin, xMax, spec.xScaleType) * plotW
end
local function mapY(val)
return padT + plotH - applyScale(val, yMin, yMax, spec.yScaleType) * plotH
end
local customState = { defs = {}, map = {}, count = 0, allowImgOverlay = true, isScatterGrid = spec.isScatterGrid }
local buf = { "" }
if spec.isScatterGrid then
buf[#buf + 1] = format('<rect x="0" y="0" width="%d" height="%d" fill="#ffffff"/>', ceil(totalW), ceil(totalH))
end
local grey = spec.persistentGrey or "#54595d"
if not hideAxes then
local xTickPxList = {}
if isOrdinalX then
local step = max(1, ceil(nPoints / max(2, floor(plotW / (abs(xAngle) > 20 and 18 or 42)))))
for j = 1, nPoints, step do
xTickPxList[#xTickPxList + 1] = { j = j, px = mapX(j, j), lbl = tostring(xArr[j] or "") }
end
else
for _, tk in ipairs(xTicks) do
if tk >= xMin - 1e-9 and tk <= xMax + 1e-9 then
local lbl = isDateX and formatDateValue(tk, spec.xAxisFormat) or formatNumber(tk, spec.xAxisFormat, spec.xType == "integer")
xTickPxList[#xTickPxList + 1] = { tk = tk, px = mapX(tk, 1), lbl = lbl }
end
end
end
for _, tk in ipairs(yTicks) do
if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
local py = mapY(tk)
if 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, spec.yAxisFormat, spec.yType == "integer")
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="%s">%s</text>', padL - 8, py + 3.8, grey, esc(lbl))
end
end
for _, xt in ipairs(xTickPxList) do
local px = xt.px
if 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 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-size="11px" fill="%s" transform="rotate(%d, %.2f, %.2f)">%s</text>',
px, ly - 3, anchor, grey, xAngle, px, ly - 3, esc(xt.lbl))
else
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', px, ly, grey, esc(xt.lbl))
end
end
-- 當 Y 軸為百分比/千分比且數值超出 [0, 1] 時,在 0% (0) 與 100% (1) 繪製帶刻度的明顯基準橫線
if needPctBoundsY then
for _, bVal in ipairs({ 0, 1 }) do
if bVal >= yMin - 1e-9 and bVal <= yMax + 1e-9 then
local py = mapY(bVal)
if abs(py - (padT + plotH)) > 1.5 then
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1.4" opacity="0.85"/>',
padL, py, padL + plotW, py, grey)
for _, xt in ipairs(xTickPxList) do
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" opacity="0.85"/>',
xt.px, py - 3.5, xt.px, py + 3.5, grey)
end
end
end
end
end
-- 當 X 軸為百分比/千分比且數值超出 [0, 1] 時,在 0% (0) 與 100% (1) 繪製帶刻度的明顯基準直線
if needPctBoundsX then
for _, bVal in ipairs({ 0, 1 }) do
if bVal >= xMin - 1e-9 and bVal <= xMax + 1e-9 then
local px = mapX(bVal, 1)
if abs(px - padL) > 1.5 then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1.4" opacity="0.85"/>',
px, padT, px, padT + plotH, grey)
for _, tk in ipairs(yTicks) do
if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
local py = mapY(tk)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" opacity="0.85"/>',
px - 3.5, py, px + 3.5, py, grey)
end
end
end
end
end
end
end
buf[#buf + 1] = format('<g clip-path="url(#gr-clip)"%s>', spec.isScatterGrid and ' shape-rendering="crispEdges"' or '')
local baseY0 = mapY(max(yMin, min(yMax, 0)))
local valTextBuf = {}
local symBuf = {}
local tipBuf = {}
local enableTips = (spec.interactive ~= false) and (not spec.isScatterGrid)
local MAX_TIPS = 100
local isScatterOrBubble = (chartType == "line") and ((tonumber(spec.linewidth) == 0) or (spec.sizes ~= nil))
local totalValidPts = 0
if enableTips then
for i = 1, nSeries do
for j = 1, nPoints do
if stackedY1[i][j] ~= nil and (chartType == "rect" or xNums[j] ~= nil) then
totalValidPts = totalValidPts + 1
end
end
end
end
local tipStep = (totalValidPts > MAX_TIPS) and (totalValidPts / MAX_TIPS) or 1
local randomTipSet = nil
if enableTips and totalValidPts > MAX_TIPS and isScatterOrBubble then
randomTipSet = {}
local pool = {}
for k = 1, totalValidPts do pool[k] = k end
for k = 1, MAX_TIPS do
local r = math.random(k, totalValidPts)
pool[k], pool[r] = pool[r], pool[k]
randomTipSet[pool[k]] = true
end
end
local globalPtIdx = 0
local function getXLabel(j, xv)
if isOrdinalX then return tostring(xArr[j] or j) end
if isDateX then return tostring(xArr[j] or formatDateValue(xv, spec.xAxisFormat)) end
return formatNumber(xv, spec.xAxisFormat, spec.xType == "integer")
end
local sizeMin, sizeMax = math.huge, -math.huge
if spec.sizes then
for i = 1, nSeries do
local sArr = spec.sizes[i]
if sArr then
for j = 1, nPoints do
local sv = tonumber(sArr[j])
if sv then
if sv < sizeMin then sizeMin = sv end
if sv > sizeMax then sizeMax = sv end
end
end
end
end
end
local cMin = spec.domainMin or yMin
local cMax = spec.domainMax or yMax
if cMax <= cMin then cMax = cMin + 1 end
for i = 1, nSeries do
local c = 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 (chartType == "area" and 1.5 or 2.5)
local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
local seriesGradUrl = nil
if hasSeriesCmap and chartType ~= "rect" then
local gradId = "gr-ser-cmap-" .. i
-- mapY(cMin) 對應圖表底部 (Y像素較大),mapY(cMax) 對應圖表頂部 (Y像素較小)
customState.defs[#customState.defs + 1] = Utils.buildColormapGradientDef(gradId, sCmap, 0, mapY(cMin), 0, mapY(cMax), true)
seriesGradUrl = "url(#" .. gradId .. ")"
end
local seriesPtIdx = 0
local seriesTipK = 0
local seriesPhase = (i - 1) % tipStep
local nextSeriesTipIdx = (totalValidPts > MAX_TIPS) and (floor(seriesPhase + 1e-9) + 1) or 1
local function shouldEmitTip()
seriesPtIdx = seriesPtIdx + 1
globalPtIdx = globalPtIdx + 1
if not enableTips or #tipBuf >= MAX_TIPS then return false end
if totalValidPts <= MAX_TIPS then return true end
if isScatterOrBubble then
return (randomTipSet and randomTipSet[globalPtIdx]) or false
end
if seriesPtIdx == nextSeriesTipIdx then
seriesTipK = seriesTipK + 1
nextSeriesTipIdx = floor(seriesTipK * tipStep + seriesPhase + 1e-9) + 1
return true
end
return false
end
if chartType == "rect" then
local defaultPad = isContinuousRect and 0.0 or 0.18
local padRatio = max(0, min(0.85, tonumber(spec.barPadding) or defaultPad))
local bandW = (plotW / max(1, nPoints)) * (1 - padRatio)
local gap = (not stacked and nSeries > 1 and not isContinuousRect) and 1.5 or 0
local subBarW = stacked and bandW or max(1.5, (bandW - (nSeries - 1) * gap) / nSeries)
local opAttr = (alpha < 1) and format(' fill-opacity="%.2f"', alpha) or ""
local strokeAttr = isContinuousRect and format(' stroke="#333333" stroke-width="%.2f"', tonumber(spec.linewidth) or 1.0) or ""
for j = 1, nPoints do
local y1v = stackedY1[i][j]
local y0v = stackedY0[i][j]
if y1v ~= nil and (not isContinuousRect or xNums[j] ~= nil) then
local rawVal = tonumber(ySeries[i][j]) or y1v
local barCol = hasSeriesCmap and Utils.sampleColormap((rawVal - cMin) / (cMax - cMin), sCmap) or baseCol
local barX, curBarW, xTipLbl
if isContinuousRect then
local xv = xNums[j]
local zw = hasRectZ and ((zSeries[i] and tonumber(zSeries[i][j])) or (zSeries[1] and tonumber(zSeries[1][j]))) or rectDefaultW
zw = abs(zw or rectDefaultW)
local rawX0 = isEdgeRect and xv or (xv - zw * 0.5)
local rawX1 = isEdgeRect and (xv + zw) or (xv + zw * 0.5)
local usableW = (rawX1 - rawX0) * (1 - padRatio)
local midX = (rawX0 + rawX1) * 0.5
local x0 = midX - usableW * 0.5
local x1 = midX + usableW * 0.5
local px0, px1 = mapX(x0, j), mapX(x1, j)
local fullPxW = max(1.5, abs(px1 - px0))
local leftPx = min(px0, px1)
if not stacked and nSeries > 1 then
local grpPad = max(0, min(0.6, tonumber(spec.groupPadding) or 0.0))
local stepPx = fullPxW / nSeries
curBarW = max(1.0, stepPx * (1 - grpPad))
barX = leftPx + (i - 1) * stepPx + (stepPx - curBarW) * 0.5
else
barX = leftPx
curBarW = fullPxW
end
if isEdgeRect then
xTipLbl = format("%s ~ %s",
formatNumber(rawX0, spec.xAxisFormat, spec.xType == "integer"),
formatNumber(rawX1, spec.xAxisFormat, spec.xType == "integer"))
else
xTipLbl = format("%s (width: %s)",
formatNumber(xv, spec.xAxisFormat, spec.xType == "integer"),
formatNumber(zw, nil, false))
end
else
local centerPx = mapX(j, j)
barX = stacked and (centerPx - bandW / 2) or (centerPx - bandW / 2 + (i - 1) * (subBarW + gap))
curBarW = subBarW
xTipLbl = getXLabel(j, j)
end
local py1 = mapY(y1v)
local py0 = stacked and mapY(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%s/>',
barX, topY, curBarW, rectH, barCol, opAttr, strokeAttr)
if spec.showValues and not stacked then
local sv = spec.showValues
local offset = tonumber(sv.offset) or -4
local fSize = tonumber(sv.fontsize) or 10
local fColor = sv.fontcolor or (offset >= 0 and "#ffffff" or grey)
local txt = formatNumber(ySeries[i][j], sv.format, spec.yType == "integer")
local tx = barX + curBarW / 2
local ty = (offset >= 0) and (topY + offset + fSize) or (topY + offset)
local haloAttr = (offset < 0) and ' data-halo="1"' or ''
valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" fill="%s"%s>%s</text>',
tx, ty, fSize, fColor, haloAttr, esc(txt))
end
if shouldEmitTip() then
local vStr = formatNumber(ySeries[i][j], spec.yAxisFormat, spec.yType == "integer")
local sTitle = spec.seriesTitles[i] or ("y" .. i)
local tipHtml = (nSeries > 1 or (sTitle ~= "y1" and sTitle ~= "y"))
and format('<b>%s</b> (%s): %s', esc(sTitle), esc(xTipLbl), esc(vStr))
or format('<b>%s</b>: %s', esc(xTipLbl), esc(vStr))
tipBuf[#tipBuf + 1] = buildRectHit(barX, topY, curBarW, rectH, totalW, totalH, tipHtml, barCol)
end
end
end
else
local segments = {}
local curSeg = {}
for j = 1, nPoints do
local xv = xNums[j]
local y1v = stackedY1[i][j]
local y0v = stackedY0[i][j] or 0
if xv ~= nil and y1v ~= nil then
local px, py1, py0 = mapX(xv, j), mapY(y1v), mapY(y0v)
local rawNum = tonumber(ySeries[i][j]) or y1v
local ptCol = hasSeriesCmap and Utils.sampleColormap((rawNum - cMin) / (cMax - cMin), sCmap) or baseCol
curSeg[#curSeg + 1] = { px, py1, py0, ySeries[i][j] }
local dynSizeVal = spec.sizes and ((spec.sizes[i] and tonumber(spec.sizes[i][j])) or (spec.sizes[1] and tonumber(spec.sizes[1][j]))) or nil
local hitD = 14
local ptSwatchSpec = nil
if (spec.symbols and spec.symbols.enabled) or dynSizeVal ~= nil then
local userR = (spec.symbols and (spec.symbols.radii[i] or spec.symbols.radii[1])) or 4.9
local r = gridRadius or userR
local symAlpha = alpha
local symStrokeW = (spec.symbols and spec.symbols.strokeWidth) or 0
if dynSizeVal ~= nil then
local scaleMul = userR / 4.9
if sizeMax > sizeMin then
local norm = max(0, (dynSizeVal - sizeMin) / (sizeMax - sizeMin))
r = (4.0 + sqrt(norm) * 14.0) * scaleMul
else
r = max(userR, sqrt(max(1, abs(dynSizeVal))) * 0.85 * scaleMul)
end
if symAlpha == 1 then symAlpha = 0.78 end
if symStrokeW == 0 then symStrokeW = 1.2 end
end
hitD = max(14, r * 2 + 4)
local shp = (spec.symbols and (spec.symbols.shapes[i] or spec.symbols.shapes[1])) or "circle"
local fillC = (spec.symbols and spec.symbols.noFill) and "none" or ptCol
symBuf[#symBuf + 1] = renderSymbol(shp, px, py1, r, fillC, ptCol, symStrokeW, symAlpha, customState)
ptSwatchSpec = { shape = shp, fill = fillC, stroke = ptCol, strokeWidth = symStrokeW, r = r }
end
if spec.showValues then
local sv = spec.showValues
local offset = tonumber(sv.offset) or -6
local fSize = tonumber(sv.fontsize) or 10
local fColor = sv.fontcolor or grey
local txt = formatNumber(ySeries[i][j], sv.format, spec.yType == "integer")
valTextBuf[#valTextBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" fill="%s" data-halo="1">%s</text>',
px, py1 + offset, fSize, fColor, esc(txt))
end
if shouldEmitTip() then
local rawVal = ySeries[i][j]
local xLbl = getXLabel(j, xv)
local vStr = formatNumber(rawVal, spec.yAxisFormat, spec.yType == "integer")
local sTitle = spec.seriesTitles[i] or ("y" .. i)
local sizeSuffix = dynSizeVal and format(' (size: %s)', esc(formatNumber(dynSizeVal, nil, false))) or ""
local tipHtml = (nSeries > 1 or (sTitle ~= "y1" and sTitle ~= "y"))
and format('<b>%s</b> (%s): %s%s', esc(sTitle), esc(xLbl), esc(vStr), sizeSuffix)
or format('<b>%s</b>: %s%s', esc(xLbl), esc(vStr), sizeSuffix)
tipBuf[#tipBuf + 1] = buildPointHit(px, py1, totalW, totalH, tipHtml, ptCol, hitD, ptSwatchSpec)
end
else
if #curSeg > 0 then segments[#segments + 1] = curSeg; curSeg = {} end
end
end
if #curSeg > 0 then segments[#segments + 1] = curSeg end
local pathCol = seriesGradUrl or baseCol
for _, seg in ipairs(segments) do
local topPath = buildPathD(seg, spec.interpolate, false)
if chartType == "area" then
local areaAlpha = (alpha < 1) and alpha or (stacked and 0.85 or 0.55)
if stacked then
local botPts = {}
for k = 1, #seg do botPts[k] = { seg[k][1], seg[k][3] } end
local botPath = buildPathD(botPts, spec.interpolate, true)
buf[#buf + 1] = format('<path d="%s %s Z" fill="%s" fill-opacity="%.2f" stroke="none"/>', topPath, botPath, pathCol, areaAlpha)
else
local firstX, lastX = seg[1][1], seg[#seg][1]
buf[#buf + 1] = format('<path d="%s L %.2f,%.2f L %.2f,%.2f Z" fill="%s" fill-opacity="%.2f" stroke="none"/>',
topPath, lastX, baseY0, firstX, baseY0, pathCol, 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, pathCol, lw, sOp)
end
end
end
end
if spec.vAnnotations then
for _, va in ipairs(spec.vAnnotations) do
local xv = isDateX and parseDateValue(va.x) or tonumber(va.x)
local px
if isOrdinalX then
for j = 1, nPoints do
if tostring(xArr[j]) == tostring(va.x) then px = mapX(j, j); break end
end
elseif xv then
px = mapX(xv, 1)
end
if px then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1.2" stroke-dasharray="4,3" opacity="0.8"/>',
px, padT, px, padT + plotH, grey)
if va.label and va.label ~= "" then
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="start" font-size="10px" fill="%s" transform="rotate(-90, %.2f, %.2f)" data-halo="1">%s</text>',
px + 4, padT + plotH - 4, grey, px + 4, padT + plotH - 4, esc(va.label))
end
end
end
end
if spec.hAnnotations then
for _, ha in ipairs(spec.hAnnotations) do
local yv = tonumber(ha.y)
if yv then
local py = mapY(yv)
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="4,3" opacity="0.8"/>',
padL, py, padL + plotW, py, grey)
if ha.label and ha.label ~= "" then
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" font-size="10px" fill="%s" data-halo="1">%s</text>',
padL + 4, py - 4, grey, esc(ha.label))
end
end
end
end
for k = 1, #symBuf do buf[#buf + 1] = symBuf[k] end
for k = 1, #valTextBuf do buf[#buf + 1] = valTextBuf[k] end
buf[#buf + 1] = '</g>'
if not hideAxes then
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
if spec.title then
buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>',
padL + plotW / 2, grey, esc(spec.title))
end
if spec.xTitle then
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s">%s</text>',
padL + plotW / 2, padT + plotH + padB - 6, grey, esc(spec.xTitle))
end
if spec.yTitle then
local yc = padT + plotH / 2
buf[#buf + 1] = format('<text x="14" y="%.1f" text-anchor="middle" font-size="12px" font-weight="bold" fill="%s" transform="rotate(-90, 14, %.1f)">%s</text>',
yc, grey, yc, esc(spec.yTitle))
end
if spec.showLegend and nSeries > 0 then
local legX = padL + plotW + 14
local legY = padT + 4
if spec.legendTitle then
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="12px" font-weight="bold" fill="%s">%s</text>',
legX, legY + 10, grey, esc(spec.legendTitle))
legY = legY + 20
end
for i = 1, nSeries do
local c = spec.colors[(i - 1) % #spec.colors + 1]
local sCmap = (spec.seriesColorMaps and spec.seriesColorMaps[i]) or spec.colorMap
local hasSeriesCmap = sCmap and Utils.isColormapName(sCmap)
local baseCol = (sCmap and not hasSeriesCmap) and sCmap or c
local iy = legY + (i - 1) * 18
if hasSeriesCmap then
local gradId = "gr-leg-cmap-" .. i
customState.defs[#customState.defs + 1] = Utils.buildColormapGradientDef(gradId, sCmap, "0%", "0%", "100%", "0%")
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="16" height="5" rx="1" fill="url(#%s)"/>', legX, iy + 3, gradId)
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>',
legX + 20, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
else
if 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, baseCol)
if spec.symbols and spec.symbols.enabled then
local shp = spec.symbols.shapes[i] or spec.symbols.shapes[1] or "circle"
local legR = min(8.5, max(4.5, spec.symbols.radii[i] or spec.symbols.radii[1] or 4.5))
customState.isLegend = true
buf[#buf + 1] = renderSymbol(shp, legX + 7, iy + 5.5, legR, spec.symbols.noFill and "none" or baseCol, baseCol, spec.symbols.strokeWidth, 1, customState)
customState.isLegend = false
end
else
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s"/>', legX, iy, baseCol)
end
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>',
legX + 18, iy + 9.5, grey, esc(spec.seriesTitles[i] or ("y" .. i)))
end
end
end
buf[1] = format('<defs><clipPath id="gr-clip"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath>%s</defs>',
padL, padT, plotW, plotH, table.concat(customState.defs))
local animCls = nil
if spec.animate ~= false and not spec.isScatterGrid then
animCls = (chartType == "rect") and "gr-anim gr-anim-grow" or "gr-anim gr-anim-fade"
end
local extraOv = FlushImgOverlays(customState, totalW, totalH) .. table.concat(tipBuf)
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', customState.count > 0, extraOv, animCls)
end
return p