模組:Graph/ChartRenderer
外观
-- Module:Graph/ChartRenderer
-- 專門畫各種類型的圖表
local p = {}
local TrackingCategory = require('Module:TrackingCategory')
local Utils = require('Module:Graph/RenderUtils')
local format, sub, gsub, match, find = string.format, string.sub, string.gsub, string.match, string.find
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 interpolateColorRamp = Utils.interpolateColorRamp
local renderSymbol = Utils.renderSymbol
local finalizeSvgWithOverlay = Utils.finalizeSvgWithOverlay
local buildPointHit = Utils.buildPointHit
local buildRectHit = Utils.buildRectHit
local FlushImgOverlays = Utils.FlushImgOverlays
-- 建構貼合扇形/環形扇區輪廓之 clip-path: polygon(...) 感應區 (供圓餅圖與極座標長條圖共用)
local function buildSectorHit(cx, cy, r0, r1, svgA0, svgA1, totalW, totalH, tipHtml, colorHex)
if not tipHtml or tipHtml == "" then return "" end
local rLo, rHi = min(r0, r1), max(r0, r1)
local dAng = max(0.02, min(2 * pi, svgA1 - svgA0))
local nSteps = max(4, min(24, ceil(dAng / 0.15)))
local pts = {}
local minPx, maxPx = math.huge, -math.huge
local minPy, maxPy = math.huge, -math.huge
local function addPt(px, py)
pts[#pts + 1] = { px, py }
if px < minPx then minPx = px end
if px > maxPx then maxPx = px end
if py < minPy then minPy = py end
if py > maxPy then maxPy = py end
end
for s = 0, nSteps do
local a = svgA0 + (s / nSteps) * dAng
addPt(cx + rHi * cos(a), cy + rHi * sin(a))
end
if rLo > 0.5 then
for s = nSteps, 0, -1 do
local a = svgA0 + (s / nSteps) * dAng
addPt(cx + rLo * cos(a), cy + rLo * sin(a))
end
elseif dAng < 2 * pi - 1e-3 then
addPt(cx, cy)
end
minPx, maxPx = minPx - 1, maxPx + 1
minPy, maxPy = minPy - 1, maxPy + 1
local boxW = max(4, maxPx - minPx)
local boxH = max(4, maxPy - minPy)
local polyCoords = {}
for k = 1, #pts do
local lx = ((pts[k][1] - minPx) / boxW) * 100
local ly = ((pts[k][2] - minPy) / boxH) * 100
polyCoords[k] = format("%.1f%% %.1f%%", max(0, min(100, lx)), max(0, min(100, ly)))
end
local hitStr = buildRectHit(minPx, minPy, boxW, boxH, totalW, totalH, tipHtml, colorHex)
local polyStr = table.concat(polyCoords, ", ")
local polySpan = format(';pointer-events:none"><span class="gr-polar-poly" style="pointer-events:auto;-webkit-clip-path:polygon(%s);clip-path:polygon(%s)"></span>',
polyStr, polyStr)
hitStr = gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-polar-hit"', 1)
-- 使用函數替換避免 Lua gsub 將百分比符號 '%' 當作跳脫字元剝除
hitStr = gsub(hitStr, '">', function() return polySpan end, 1)
return hitStr
end
-- 解析極座標起始角度與旋轉方向:
-- startAngle >= 0 (含 0, -0):從 (startAngle % 360) 度起算,逆時針旋轉 (svgDir = -1)
-- startAngle < 0 (如 -270, -360):從 (startAngle % 360) 度起算,順時針旋轉 (svgDir = 1)
local function resolvePolarTransform(spec, defaultStartDeg)
local startDeg = tonumber(spec.startAngle)
if startDeg == nil then startDeg = defaultStartDeg or 0 end
local svgDir = (startDeg < 0) and 1 or -1
local mathZeroRad = (startDeg % 360) * (pi / 180)
local svgZero = -mathZeroRad
local function toSvgAngle(radOffset)
return svgZero + svgDir * radOffset
end
return svgZero, svgDir, toSvgAngle
end
-- 判斷極座標 X 軸單位:支援 "turn" (0~1 轉,含 % 與 ‰ 自動觸發)、"radian"、"degree" 與 "category"
local function resolvePolarXUnit(spec, allXNumeric, dataXMax, maxZWidth, forceContinuous)
local xFmtLow = string.lower(tostring(spec.xAxisFormat or ""))
local xTypeLow = string.lower(tostring(spec.xType or ""))
if find(xFmtLow, "%", 1, true) or find(xFmtLow, "‰", 1, true)
or find(xFmtLow, "turn", 1, true) or find(xTypeLow, "turn", 1, true) then
return "turn"
elseif find(xFmtLow, "rad", 1, true) or find(xTypeLow, "rad", 1, true) then
return "radian"
elseif find(xFmtLow, "deg", 1, true) or find(xFmtLow, "angle", 1, true) or find(xFmtLow, "°", 1, true)
or find(xTypeLow, "deg", 1, true) or find(xTypeLow, "angle", 1, true) then
return "degree"
elseif allXNumeric and forceContinuous then
if dataXMax > 7.0 or (maxZWidth and maxZWidth > 7.0) then
return "degree"
else
return "radian"
end
end
return "category"
end
-- 格式化極座標 8 等分放射軸刻度標籤 (k = 0..7)
local function formatPolarRadialTick(k, xUnit, xAxisFormat)
if xUnit == "turn" then
local turnVal = k / 8
if xAxisFormat and (find(xAxisFormat, "%", 1, true) or find(xAxisFormat, "‰", 1, true)) then
return formatNumber(turnVal, xAxisFormat, false)
end
return (k == 0) and "0" or (formatNumber(turnVal, nil, false) .. " turn")
elseif xUnit == "radian" then
local radLabels = { "0", "π/4", "π/2", "3π/4", "π", "5π/4", "3π/2", "7π/4" }
return radLabels[k + 1]
else
return format("%d°", k * 45)
end
end
-- 圓餅圖 / 環圈圖渲染器 (Pie / Donut Chart)
local function renderPieChart(spec)
local plotW = max(100, spec.width or 200)
local plotH = max(100, spec.height or 200)
local grey = spec.persistentGrey or "#54595d"
local xArr = spec.x or {}
local yArr = (spec.y and spec.y[1]) or {}
local nSlices = max(xArr.n or #xArr, yArr.n or #yArr)
local hasPieCmap = spec.colorMap and Utils.isColormapName(spec.colorMap)
local slices, total = {}, 0
for i = 1, nSlices do
local v = tonumber(yArr[i])
if v and v > 0 then
total = total + v
local col = hasPieCmap
and Utils.sampleColormap((i - 1) / max(1, nSlices - 1), spec.colorMap)
or spec.colors[(i - 1) % #spec.colors + 1]
slices[#slices + 1] = {
idx = i,
label = tostring(xArr[i] or ("項目" .. i)),
val = v,
color = col,
alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 1
}
end
end
if total <= 0 then total = 1 end
local legW = 0
if spec.showLegend and #slices > 0 then
for _, s in ipairs(slices) do
legW = max(legW, mw.ustring.len(s.label) * 7.5 + 32)
end
legW = min(220, max(80, legW))
end
local padT = spec.title and 32 or 12
local padB = 14
local padL = 14
local padR = 14 + legW
local outerR = spec.outerRadius or (min(plotW, plotH) / 2 - 6)
outerR = max(24, outerR)
local innerR = max(0, min(outerR - 4, tonumber(spec.innerRadius) or 0))
local totalW = padL + plotW + padR
local totalH = max(padT + plotH + padB, padT + (#slices * 18 + 24) + padB)
local cx = padL + plotW / 2
local cy = padT + plotH / 2
local buf = {}
local textBuf = {}
local tipBuf = {}
local _, _, toSvgAngle = resolvePolarTransform(spec, -270)
local curRad = 0
for _, s in ipairs(slices) do
local frac = s.val / total
local dAng = frac * 2 * pi
local nextRad = curRad + dAng
local a0 = toSvgAngle(curRad)
local a1 = toSvgAngle(nextRad)
local svgA0, svgA1 = min(a0, a1), max(a0, a1)
local midAng = (svgA0 + svgA1) * 0.5
local opAttr = (s.alpha < 1) and format(' fill-opacity="%.2f"', s.alpha) or ""
if frac >= 0.9999 then
if innerR > 0 then
local d = format("M %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f M %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f Z",
cx, cy - outerR, outerR, outerR, cx, cy + outerR, outerR, outerR, cx, cy - outerR,
cx, cy - innerR, innerR, innerR, cx, cy + innerR, innerR, innerR, cx, cy - innerR)
buf[#buf + 1] = format('<path d="%s" fill="%s" fill-rule="evenodd" stroke="#ffffff" stroke-width="1"%s/>', d, s.color, opAttr)
else
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"%s/>', cx, cy, outerR, s.color, opAttr)
end
else
local largeArc = (dAng > pi) and 1 or 0
local x1, y1 = cx + outerR * cos(svgA0), cy + outerR * sin(svgA0)
local x2, y2 = cx + outerR * cos(svgA1), cy + outerR * sin(svgA1)
if innerR > 0 then
local ix1, iy1 = cx + innerR * cos(svgA1), cy + innerR * sin(svgA1)
local ix2, iy2 = cx + innerR * cos(svgA0), cy + innerR * sin(svgA0)
buf[#buf + 1] = format('<path d="M %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,0 %.2f,%.2f Z" fill="%s" stroke="#ffffff" stroke-width="1"%s/>',
x1, y1, outerR, outerR, largeArc, x2, y2, ix1, iy1, innerR, innerR, largeArc, ix2, iy2, s.color, opAttr)
else
buf[#buf + 1] = format('<path d="M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f Z" fill="%s" stroke="#ffffff" stroke-width="1"%s/>',
cx, cy, x1, y1, outerR, outerR, largeArc, x2, y2, s.color, opAttr)
end
end
if spec.showValues and frac >= 0.035 then
local sv = spec.showValues
local offset = tonumber(sv.offset) or -18
local fSize = tonumber(sv.fontsize) or 11
local fColor = sv.fontcolor or (offset < 0 and "#ffffff" or grey)
local lblR = (offset < 0) and max((innerR + outerR) / 2, outerR + offset) or (outerR + offset)
local lx, ly = cx + lblR * cos(midAng), cy + lblR * sin(midAng)
local txt = formatNumber(s.val, sv.format, spec.yType == "integer")
local haloAttr = (offset >= 0) and ' data-halo="1"' or ''
textBuf[#textBuf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="%dpx" font-weight="bold" fill="%s"%s>%s</text>',
lx, ly + 4, fSize, fColor, haloAttr, esc(txt))
end
if spec.interactive ~= false then
local valStr = formatNumber(s.val, spec.yAxisFormat, spec.yType == "integer")
local tipHtml = format('<b>%s</b>: %s (%.1f%%)', esc(s.label), esc(valStr), frac * 100)
tipBuf[#tipBuf + 1] = buildSectorHit(cx, cy, innerR, outerR, svgA0, svgA1, totalW, totalH, tipHtml, s.color)
end
curRad = nextRad
end
for i = 1, #textBuf do buf[#buf + 1] = textBuf[i] 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>',
cx, grey, esc(spec.title))
end
if spec.showLegend and #slices > 0 then
local legX = padL + plotW + 10
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, s in ipairs(slices) do
local iy = legY + (i - 1) * 18
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="12" height="11" rx="2" fill="%s"/>', legX, iy, s.color)
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" font-size="11px" fill="%s">%s</text>',
legX + 18, iy + 9.5, grey, esc(s.label))
end
end
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-radial" or nil
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end
-- 區間浮動長條圖渲染器 (採用 SVG 原生內嵌 <style> @keyframes 實現全歷史長卷軸與底部選擇框 100% 同步巡航)
local function renderRangedRectChart(spec)
local rr = spec.rangedRect
local recs = rr.records or {}
local plotW = max(150, spec.width or 500)
local mainH = max(80, spec.height or 200)
local showOv = rr.showOverview
local ovH = showOv and (rr.overviewHeight or 40) or 0
local gapH = showOv and (rr.gapHeight or 26) or 0
if showOv and mainH > (ovH + gapH + 60) then
mainH = mainH - ovH - gapH
end
local grey = spec.persistentGrey or "#54595d"
local items = {}
local allXMin, allXMax = math.huge, -math.huge
local allYMin, allYMax = math.huge, -math.huge
local idx = 1
while recs[idx] ~= nil do
local r = recs[idx]
local xv = parseDateValue(r[rr.xField] or r.date or r.x)
local yLo = tonumber(r[rr.yLowField])
local yHi = tonumber(r[rr.yHighField])
local cVal = rr.colorField and tonumber(r[rr.colorField]) or ((yLo and yHi) and ((yLo + yHi) / 2) or 0)
if xv and yLo and yHi then
items[#items + 1] = { x = xv, yLo = min(yLo, yHi), yHi = max(yLo, yHi), c = cVal }
if xv < allXMin then allXMin = xv end
if xv > allXMax then allXMax = xv end
if min(yLo, yHi) < allYMin then allYMin = min(yLo, yHi) end
if max(yLo, yHi) > allYMax then allYMax = max(yLo, yHi) end
end
idx = idx + 1
end
if #items == 0 then
allXMin, allXMax, allYMin, allYMax = 2000, 2020, 0, 30
end
local spanYears = rr.initYears or 7
local totalSpan = max(1, allXMax - allXMin)
local isPannable = (spec.animate ~= false) and (totalSpan > spanYears * 1.15)
local viewXMax = rr.maxDate and parseDateValue(rr.maxDate) or allXMax
local viewXMin = rr.minDate and parseDateValue(rr.minDate) or max(allXMin, viewXMax - spanYears)
if viewXMax <= viewXMin then viewXMin = allXMin; viewXMax = allXMax end
local yTicks, niceYMin, niceYMax = computeNiceTicks(allYMin, allYMax, max(4, floor(mainH / 34)), "linear", false)
local padL = 46 + (spec.yTitle and 16 or 0)
local padR = 20
local padT = (spec.title and 30 or 14)
local padB = 28 + (spec.xTitle and 16 or 0)
local totalW = padL + plotW + padR
local totalH = padT + mainH + (showOv and (gapH + ovH + 22) or 0) + padB
local function mapMainY(v) return padT + mainH - ((v - niceYMin) / max(1e-6, niceYMax - niceYMin)) * mainH end
local buf = {}
local extraOvBuf = {}
local rampDomain = rr.colorDomain or { -15, 0, 15, 30 }
local rampRange = rr.colorRange or { "#2b83ba", "#abdda4", "#fdae61", "#d7191c" }
local fillOp = rr.fillOpacity or 0.45
local strokeW = rr.strokeWidth or 1.4
local ratio = min(25.0, max(1.3, totalSpan / max(1, spanYears)))
local trackW = plotW * ratio
-- 降低總秒數:乘數由 9.5 改為 4.0,下限由 32 秒改為 12 秒,上限 120 秒
local animDur = min(90, max(12, floor(ratio * 2.0)))
-- 將長條圖資料分箱聚合為最多 100 個感應區,避免撐爆模板展開大小並確保懸停精準對齊
-- 將長條圖資料分箱聚合為最多 100 個感應區,避免撐爆模板展開大小並確保懸停精準對齊
local function buildBinnedHits(itemList, xMinVal, xMaxVal, xOffset, plotWidth, mapYFn, containerW, containerH)
local hits = {}
if spec.interactive == false or #itemList == 0 then return hits end
local MAX_BINS = 100
local uStr = rr.unitLabel or ""
local span = max(1e-6, xMaxVal - xMinVal)
local safeBottomY = containerH - 30
if #itemList <= MAX_BINS then
local bw = max(2.0, min(14, (plotWidth / max(12, #itemList)) * 0.72))
for _, it in ipairs(itemList) do
local px = xOffset + ((it.x - xMinVal) / span) * plotWidth
local pyHi = mapYFn(it.yHi)
local pyLo = mapYFn(it.yLo)
local bh = max(2.0, pyLo - pyHi)
if (pyHi / max(1, containerH)) < 0.32 and (pyHi + bh > safeBottomY) then
bh = max(2.0, safeBottomY - pyHi)
end
local col = interpolateColorRamp(it.c, rampDomain, rampRange)
local dStr = formatDateValue(it.x, "%Y-%m")
local tipHtml = format('<b>%s</b>: %s ~ %s%s',
esc(dStr), esc(formatNumber(it.yLo, nil, false)), esc(formatNumber(it.yHi, nil, false)), esc(uStr))
hits[#hits + 1] = buildRectHit(px - bw / 2, pyHi, bw, bh, containerW, containerH, tipHtml, col)
end
else
local bins = {}
for _, it in ipairs(itemList) do
local bIdx = max(1, min(MAX_BINS, floor(((it.x - xMinVal) / span) * MAX_BINS) + 1))
local b = bins[bIdx]
if not b then
b = { minX = it.x, maxX = it.x, yLo = it.yLo, yHi = it.yHi, sumC = it.c, count = 1 }
bins[bIdx] = b
else
if it.x < b.minX then b.minX = it.x end
if it.x > b.maxX then b.maxX = it.x end
if it.yLo < b.yLo then b.yLo = it.yLo end
if it.yHi > b.yHi then b.yHi = it.yHi end
b.sumC = b.sumC + it.c
b.count = b.count + 1
end
end
local binW = plotWidth / MAX_BINS
for bIdx = 1, MAX_BINS do
local b = bins[bIdx]
if b then
local bx = xOffset + (bIdx - 1) * binW
local pyHi = mapYFn(b.yHi)
local pyLo = mapYFn(b.yLo)
local bh = max(4.0, pyLo - pyHi)
if (pyHi / max(1, containerH)) < 0.32 and (pyHi + bh > safeBottomY) then
bh = max(4.0, safeBottomY - pyHi)
end
local avgC = b.sumC / b.count
local col = interpolateColorRamp(avgC, rampDomain, rampRange)
local d0 = formatDateValue(b.minX, "%Y-%m")
local d1 = formatDateValue(b.maxX, "%Y-%m")
local dLbl = (d0 == d1) and d0 or (d0 .. " ~ " .. d1)
local tipHtml = format('<b>%s</b>: %s ~ %s%s',
esc(dLbl), esc(formatNumber(b.yLo, nil, false)), esc(formatNumber(b.yHi, nil, false)), esc(uStr))
hits[#hits + 1] = buildRectHit(bx, pyHi, binW, bh, containerW, containerH, tipHtml, col)
end
end
end
return hits
end
if not isPannable then
buf[#buf + 1] = format('<defs><clipPath id="gr-rr-clip"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/></clipPath></defs>', padL, padT, plotW, mainH)
end
for _, tk in ipairs(yTicks) do
local py = mapMainY(tk)
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#e1e3e5" stroke-width="1"/>', padL, py, padL + plotW, py)
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="%s" stroke-width="1"/>', padL - 5, py, padL, py, grey)
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(formatNumber(tk, spec.yAxisFormat, false)))
end
if isPannable then
-- 生成第二個超長 SVG 與對應的 100 格聚合 Tooltip,放入 HTML 滾動軌道中以支援 :hover 同步暫停
local trackWInt = max(1, ceil(trackW))
local vpH = mainH + 22
local vpHInt = max(1, ceil(vpH))
local function mapPanX(v) return ((v - allXMin) / totalSpan) * trackWInt end
local function mapTrackY(v) return mainH - ((v - niceYMin) / max(1e-6, niceYMax - niceYMin)) * mainH end
local trackBuf = {}
for _, tk in ipairs(yTicks) do
local py = mapTrackY(tk)
trackBuf[#trackBuf + 1] = format('<line x1="0" y1="%.2f" x2="%d" y2="%.2f" stroke="#e1e3e5" stroke-width="1"/>', py, trackWInt, py)
end
local yStep = (totalSpan > 80) and 2 or 1
local firstTickY = ceil(allXMin)
if yStep == 2 and (firstTickY % 2 ~= 0) then firstTickY = firstTickY + 1 end
local lastTickY = floor(allXMax)
for yr = firstTickY, lastTickY, yStep do
local px = mapPanX(yr)
trackBuf[#trackBuf + 1] = format('<line x1="%.1f" y1="0" x2="%.1f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, px, mainH)
trackBuf[#trackBuf + 1] = format('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, mainH, px, mainH + 5, grey)
trackBuf[#trackBuf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="%s">%d</text>',
px, mainH + 15, grey, yr)
end
if niceYMin < 0 and niceYMax > 0 then
local py0 = mapTrackY(0)
trackBuf[#trackBuf + 1] = format('<line x1="0" y1="%.2f" x2="%d" y2="%.2f" stroke="#888888" stroke-width="1" stroke-dasharray="4,3"/>',
py0, trackWInt, py0)
end
local barW = max(1.8, min(12, (trackWInt / max(12, #items)) * 0.72))
for _, it in ipairs(items) do
local px = mapPanX(it.x)
local pyHi = mapTrackY(it.yHi)
local pyLo = mapTrackY(it.yLo)
local bh = max(1.5, pyLo - pyHi)
local col = interpolateColorRamp(it.c, rampDomain, rampRange)
trackBuf[#trackBuf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.1f"/>',
px - barW / 2, pyHi, barW, bh, col, fillOp, col, strokeW)
end
trackBuf[#trackBuf + 1] = format('<line x1="0" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1.8"/>', mainH, trackWInt, mainH, grey)
local trackImgHtml = mw.svg.new()
:setAttribute('viewBox', format('0 0 %d %d', trackWInt, vpHInt))
:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
:setImgAttribute('width', tostring(trackWInt))
:setImgAttribute('height', tostring(vpHInt))
:setImgAttribute('class', 'graph-chart-svg')
:setContent(table.concat(trackBuf))
:toImage()
local trackHits = buildBinnedHits(items, allXMin, allXMax, 0, trackWInt, mapTrackY, trackWInt, vpHInt)
local vpLeftPct = (padL / totalW) * 100
local vpTopPct = (padT / totalH) * 100
local vpWPct = (plotW / totalW) * 100
local vpHPct = (vpH / totalH) * 100
local panMoverWPct = (ratio - 1) * 100
local trackWPct = (ratio / (ratio - 1)) * 100
extraOvBuf[#extraOvBuf + 1] = format(
'<div class="gr-rr-vp" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%">' ..
'<div class="gr-rr-pan-mover" style="width:%.2f%%;animation-duration:%ds;will-change:transform">' ..
'<div class="gr-rr-track" style="width:%.2f%%">%s%s</div>' ..
'</div></div>',
vpLeftPct, vpTopPct, vpWPct, vpHPct,
panMoverWPct, animDur, trackWPct,
trackImgHtml, table.concat(trackHits)
)
else
local function mapMainX(v) return padL + ((v - viewXMin) / max(1e-6, viewXMax - viewXMin)) * plotW end
local xTicks = computeNiceTicks(viewXMin, viewXMax, max(4, floor(plotW / 80)), "linear", true)
for _, tk in ipairs(xTicks) do
if tk >= viewXMin - 1e-6 and tk <= viewXMax + 1e-6 then
local px = mapMainX(tk)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + mainH)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, padT + mainH, px, padT + mainH + 5, grey)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', px, padT + mainH + 15, grey, esc(formatDateValue(tk, "%Y")))
end
end
local barW = max(2.0, min(14, (plotW / max(12, (viewXMax - viewXMin) * 12)) * 0.72))
buf[#buf + 1] = '<g clip-path="url(#gr-rr-clip)">'
if niceYMin < 0 and niceYMax > 0 then
local py0 = mapMainY(0)
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#888888" stroke-width="1" stroke-dasharray="4,3"/>', padL, py0, padL + plotW, py0)
end
local visItems = {}
for _, it in ipairs(items) do
if it.x >= viewXMin - 0.2 and it.x <= viewXMax + 0.2 then
local px = mapMainX(it.x)
local pyHi = mapMainY(it.yHi)
local pyLo = mapMainY(it.yLo)
local bh = max(1.5, pyLo - pyHi)
local col = interpolateColorRamp(it.c, rampDomain, rampRange)
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.1f"/>',
px - barW / 2, pyHi, barW, bh, col, fillOp, col, strokeW)
visItems[#visItems + 1] = it
end
end
buf[#buf + 1] = '</g>'
local staticHits = buildBinnedHits(visItems, viewXMin, viewXMax, padL, plotW, mapMainY, totalW, totalH)
for i = 1, #staticHits do extraOvBuf[#extraOvBuf + 1] = staticHits[i] end
end
buf[#buf + 1] = format('<polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="%s" stroke-width="1.8"/>', padL, padT, padL, padT + mainH, padL + plotW, padT + mainH, grey)
if showOv then
local ovTop = padT + mainH + gapH + 10
local function mapOvX(v) return padL + ((v - allXMin) / totalSpan) * plotW end
local function mapOvY(v) return ovTop + ovH - ((v - niceYMin) / max(1e-6, niceYMax - niceYMin)) * ovH end
local ovBarW = max(1.0, (plotW / max(12, #items)) * 0.75)
for _, it in ipairs(items) do
local px = mapOvX(it.x)
local pyHi, pyLo = mapOvY(it.yHi), mapOvY(it.yLo)
local col = interpolateColorRamp(it.c, rampDomain, rampRange)
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" fill-opacity="0.65"/>',
px - ovBarW / 2, pyHi, ovBarW, max(1, pyLo - pyHi), col)
end
if isPannable then
local ovLeftPct = (padL / totalW) * 100
local ovTopPct = (ovTop / totalH) * 100
local ovWPct = (plotW / totalW) * 100
local ovHPct = (ovH / totalH) * 100
local winMoverWPct = ((ratio - 1) / ratio) * 100
local winBoxWPct = (1 / (ratio - 1)) * 100
extraOvBuf[#extraOvBuf + 1] = format(
'<div class="gr-rr-ov-vp" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%;pointer-events:none">' ..
'<div class="gr-rr-win-mover" style="width:%.2f%%;animation-duration:%ds;will-change:transform">' ..
'<div class="gr-rr-win" style="width:%.2f%%"></div>' ..
'</div></div>',
ovLeftPct, ovTopPct, ovWPct, ovHPct,
winMoverWPct, animDur, winBoxWPct
)
else
local selX0, selX1 = mapOvX(viewXMin), mapOvX(viewXMax)
buf[#buf + 1] = format('<rect x="%.2f" y="%.1f" width="%.2f" height="%.1f" fill="#333333" fill-opacity="0.12" stroke="#54595d" stroke-width="1"/>',
selX0, ovTop, max(4, selX1 - selX0), ovH)
end
buf[#buf + 1] = format('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="1.2"/>', padL, ovTop + ovH, padL + plotW, ovTop + ovH, grey)
local ovTicks = computeNiceTicks(allXMin, allXMax, max(4, floor(plotW / 90)), "linear", true)
for _, tk in ipairs(ovTicks) do
if tk >= allXMin - 1e-6 and tk <= allXMax + 1e-6 then
local px = mapOvX(tk)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="%s" stroke-width="1"/>', px, ovTop + ovH, px, ovTop + ovH + 4, grey)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="10px" fill="%s">%s</text>', px, ovTop + ovH + 14, grey, esc(formatDateValue(tk, "%Y")))
end
end
if rr.overviewXTitle then
buf[#buf + 1] = format('<text x="%.1f" y="%.1f" text-anchor="middle" font-size="11px" font-weight="bold" fill="%s">%s</text>',
padL + plotW / 2, ovTop + ovH + 28, grey, esc(rr.overviewXTitle))
end
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.yTitle then
local yc = padT + mainH / 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 rr.unitLabel then
buf[#buf + 1] = format('<text x="24" y="22" text-anchor="middle" font-size="13px" font-weight="bold" fill="%s">%s</text>', grey, esc(rr.unitLabel))
end
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-fade" or nil
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(extraOvBuf), animCls)
end
-- 箱形圖渲染器 (Box Plot)
local function renderBoxPlotChart(spec)
local plotW = max(80, spec.width or 300)
local plotH = max(60, spec.height or 200)
local grey = spec.persistentGrey or "#54595d"
local boxes = {}
local dataYMin, dataYMax = math.huge, -math.huge
if spec.boxPlot and spec.boxPlot.records then
local bp = spec.boxPlot
local recs = bp.records
local idx = 1
while recs[idx] ~= nil do
local r = recs[idx]
local lbl = tostring(r[bp.xField] or idx)
local vMin = tonumber(r[bp.minField] or r.min or r.low)
local vQ1 = tonumber(r[bp.q1Field] or r.q1)
local vQ3 = tonumber(r[bp.q3Field] or r.q3)
local vMax = tonumber(r[bp.maxField] or r.max or r.high)
local vMed = tonumber(r[bp.medianField] or r.median or r.mid) or ((vQ1 and vQ3) and ((vQ1 + vQ3) / 2) or nil)
if vMin and vQ1 and vQ3 and vMax then
boxes[#boxes + 1] = { label = lbl, min = vMin, q1 = vQ1, med = vMed, q3 = vQ3, max = vMax }
if vMin < dataYMin then dataYMin = vMin end
if vMax > dataYMax then dataYMax = vMax end
end
idx = idx + 1
end
else
local xArr = spec.x or {}
local y = spec.y or {}
local nPts = xArr.n or #xArr
for j = 1, nPts do
local vMin = y[1] and tonumber(y[1][j])
local vQ1 = y[2] and tonumber(y[2][j])
local vMed = y[3] and tonumber(y[3][j])
local vQ3 = y[4] and tonumber(y[4][j])
local vMax = y[5] and tonumber(y[5][j])
if vMin and vQ1 and vMed and vQ3 and vMax then
boxes[#boxes + 1] = { label = tostring(xArr[j] or j), min = vMin, q1 = vQ1, med = vMed, q3 = vQ3, max = vMax }
if vMin < dataYMin then dataYMin = vMin end
if vMax > dataYMax then dataYMax = vMax end
end
end
end
if dataYMin == math.huge then dataYMin, dataYMax = 0, 10 end
local isPctY = spec.yAxisFormat and (string.find(spec.yAxisFormat, "%", 1, true) ~= nil or string.find(spec.yAxisFormat, "‰", 1, true) ~= nil)
local yTicks, niceYMin, niceYMax = computeNiceTicks(tonumber(spec.yMin) or dataYMin, tonumber(spec.yMax) or dataYMax, max(4, floor(plotH / 36)), "linear", false)
local needPctBoundsY = isPctY and (dataYMin < -1e-9 or dataYMax > 1 + 1e-9 or niceYMin < -1e-9 or niceYMax > 1 + 1e-9)
if needPctBoundsY then
for _, bVal in ipairs({ 0, 1 }) do
if bVal >= niceYMin - 1e-9 and bVal <= niceYMax + 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
local padL, padR, padT, padB = 46 + (spec.yTitle and 18 or 0), 18, (spec.title and 30 or 14), 30 + (spec.xTitle and 18 or 0)
local totalW, totalH = padL + plotW + padR, padT + plotH + padB
local nBoxes = max(1, #boxes)
local function mapY(v) return padT + plotH - ((v - niceYMin) / (niceYMax - niceYMin)) * plotH end
local buf = {}
for _, tk in ipairs(yTicks) do
local py = mapY(tk)
if spec.yGrid ~= false 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)
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(formatNumber(tk, spec.yAxisFormat, false)))
end
if needPctBoundsY then
for _, bVal in ipairs({ 0, 1 }) do
if bVal >= niceYMin - 1e-9 and bVal <= niceYMax + 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 j = 1, #boxes do
local cx = padL + ((j - 0.5) / nBoxes) * plotW
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" opacity="0.85"/>',
cx, py - 3.5, cx, py + 3.5, grey)
end
end
end
end
end
local boxW = min(44, (plotW / nBoxes) * 0.55)
local tipBuf = {}
for j = 1, #boxes do
local b = boxes[j]
local cx = padL + ((j - 0.5) / nBoxes) * plotW
local pyMin, pyQ1, pyMed, pyQ3, pyMax = mapY(b.min), mapY(b.q1), mapY(b.med), mapY(b.q3), mapY(b.max)
local col = spec.colors[(j - 1) % #spec.colors + 1]
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.5"/>', cx, pyMax, cx, pyMin, grey)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.5"/>', cx - boxW * 0.3, pyMax, cx + boxW * 0.3, pyMax, grey)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.5"/>', cx - boxW * 0.3, pyMin, cx + boxW * 0.3, pyMin, grey)
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="0.75" stroke="%s" stroke-width="1.5"/>',
cx - boxW / 2, min(pyQ1, pyQ3), boxW, max(2, abs(pyQ1 - pyQ3)), col, col)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#ffffff" stroke-width="2"/>', cx - boxW / 2, pyMed, cx + boxW / 2, pyMed)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', cx, padT + plotH + 16, grey, esc(b.label))
if spec.interactive ~= false then
local fMed = formatNumber(b.med, spec.yAxisFormat, false)
local fQ1 = formatNumber(b.q1, spec.yAxisFormat, false)
local fQ3 = formatNumber(b.q3, spec.yAxisFormat, false)
local fMin = formatNumber(b.min, spec.yAxisFormat, false)
local fMax = formatNumber(b.max, spec.yAxisFormat, false)
local tipHtml = format('<b>%s</b> | Med: %s (Q1: %s, Q3: %s, Min: %s, Max: %s)',
esc(b.label), esc(fMed), esc(fQ1), esc(fQ3), esc(fMin), esc(fMax))
tipBuf[#tipBuf + 1] = buildRectHit(cx - boxW / 2, min(pyMax, pyMin), boxW, max(4, abs(pyMin - pyMax)), totalW, totalH, tipHtml, col)
end
end
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)
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
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-grow" or nil
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end
-- 甘特圖渲染器 (Gantt Chart)
local function renderGanttChart(spec)
local plotW = max(100, spec.width or 400)
local plotH = max(60, spec.height or 200)
local grey = spec.persistentGrey or "#54595d"
local tasks, yCats, yCatMap = {}, {}, {}
local dataXMin, dataXMax = math.huge, -math.huge
local isDate = (spec.xType == "date")
local function parseXVal(v)
if v == nil or v == false then return nil end
local num = tonumber(v)
if num then return num end
local dv = parseDateValue(v)
if dv then isDate = true; return dv end
return nil
end
if spec.gantt and spec.gantt.records then
local gt = spec.gantt
local recs = gt.records
local idx = 1
while recs[idx] ~= nil do
local r = recs[idx]
local yLbl = tostring(r[gt.yField] or idx)
local x0 = parseXVal(r[gt.xStartField])
local x1 = parseXVal(r[gt.xEndField])
if x0 and x1 then
if not yCatMap[yLbl] then yCats[#yCats + 1] = yLbl; yCatMap[yLbl] = #yCats end
tasks[#tasks + 1] = { yi = yCatMap[yLbl], x0 = min(x0, x1), x1 = max(x0, x1), group = gt.colorField and tostring(r[gt.colorField] or "") or yLbl }
if min(x0, x1) < dataXMin then dataXMin = min(x0, x1) end
if max(x0, x1) > dataXMax then dataXMax = max(x0, x1) end
end
idx = idx + 1
end
else
local xArr = spec.x or {}
local y1 = spec.y and spec.y[1] or {}
local y2 = (spec.y and spec.y[2]) or spec.x2 or {}
local nPts = xArr.n or #xArr
for j = 1, nPts do
local yLbl = tostring(xArr[j] or j)
local x0, x1 = parseXVal(y1[j]), parseXVal(y2[j])
if x0 and x1 then
if not yCatMap[yLbl] then yCats[#yCats + 1] = yLbl; yCatMap[yLbl] = #yCats end
tasks[#tasks + 1] = { yi = yCatMap[yLbl], x0 = min(x0, x1), x1 = max(x0, x1), group = tostring(j) }
if min(x0, x1) < dataXMin then dataXMin = min(x0, x1) end
if max(x0, x1) > dataXMax then dataXMax = max(x0, x1) end
end
end
end
if dataXMin == math.huge then dataXMin, dataXMax = 0, 10 end
local xTicks, niceXMin, niceXMax = computeNiceTicks(dataXMin, dataXMax, max(4, floor(plotW / 65)), "linear", not isDate)
if isDate then niceXMin, niceXMax = dataXMin, dataXMax end
if niceXMax <= niceXMin then niceXMax = niceXMin + 1 end
local maxYLen = 4
for _, c in ipairs(yCats) do maxYLen = max(maxYLen, mw.ustring.len(c)) end
local padL = max(54, min(160, maxYLen * 7.5 + 16))
local padR, padT, padB = 18, (spec.title and 30 or 14), 28 + (spec.xTitle and 18 or 0)
local totalW, totalH = padL + plotW + padR, padT + plotH + padB
local nCats = max(1, #yCats)
local bandH = plotH / nCats
local barH = max(4, min(24, bandH * 0.65))
local function mapX(v) return padL + ((v - niceXMin) / (niceXMax - niceXMin)) * plotW end
local buf = {}
for _, tk in ipairs(xTicks) do
if tk >= niceXMin - 1e-9 and tk <= niceXMax + 1e-9 then
local px = mapX(tk)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#e1e3e5" stroke-width="1"/>', px, padT, px, padT + plotH)
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 = isDate and formatDateValue(tk, spec.xAxisFormat) or formatNumber(tk, spec.xAxisFormat, false)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="%s">%s</text>', px, padT + plotH + 16, grey, esc(lbl))
end
end
for i = 1, #yCats do
local cy = padT + (i - 0.5) * bandH
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="%s">%s</text>', padL - 8, cy + 3.8, grey, esc(yCats[i]))
end
local tipBuf = {}
for _, t in ipairs(tasks) do
local cy = padT + (t.yi - 0.5) * bandH
local px0, px1 = mapX(t.x0), mapX(t.x1)
local bw = max(2, px1 - px0)
local col = spec.colors[(t.yi - 1) % #spec.colors + 1]
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="2" fill="%s" fill-opacity="0.88"/>',
px0, cy - barH / 2, bw, barH, col)
if spec.interactive ~= false and #tipBuf < 200 then
local s0 = isDate and formatDateValue(t.x0, spec.xAxisFormat) or formatNumber(t.x0, spec.xAxisFormat, false)
local s1 = isDate and formatDateValue(t.x1, spec.xAxisFormat) or formatNumber(t.x1, spec.xAxisFormat, false)
local tipHtml = format('<b>%s</b>: %s ~ %s', esc(yCats[t.yi] or ""), esc(s0), esc(s1))
tipBuf[#tipBuf + 1] = buildRectHit(px0, cy - barH / 2, bw, barH, totalW, totalH, tipHtml, col)
end
end
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)
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
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-fade" or nil
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end
-- 二維矩陣熱圖與像素方格圖渲染器 (Correlation Heatmap / QR Code / Pixel Art)
local function renderHeatmapChart(spec)
local plotW = max(80, spec.width or 300)
local plotH = max(80, spec.height or 260)
local grey = spec.persistentGrey or "#54595d"
local hm = spec.heatmap or {}
-- 自動判定:若明確標記為 isPixelMode,或同時關閉座標軸與圖例,即進入 QR Code / 像素畫無框模式
local isPixelMode = (hm.isPixelMode == true) or (spec.hideAxes and not spec.showLegend)
local qrTooltip = hm.qrTooltip
local scaleColorMap = hm.scaleColorMap
local xCats, xMap, yCats, yMap, cells = {}, {}, {}, {}, {}
local allXNumeric, allYNumeric = true, true
local cMin, cMax = math.huge, -math.huge
local hasNumericColor = false
-- 相容讀取陣列索引 (將 Vega 傳來的字串 "0", "1" 轉為 Lua 的 1-based 陣列)
local function getVal(r, f)
if r[f] ~= nil then return r[f] end
local n = tonumber(f)
if n ~= nil then
if r[1] ~= nil and r[0] == nil then return r[n + 1] end
return r[n]
end
return nil
end
if hm.records then
local recs = hm.records
local xField = hm.xField or "x"
local yField = hm.yField or "y"
local cField = hm.colorField or "c"
local idx = 1
while recs[idx] ~= nil do
local r = recs[idx]
local xv, yv = getVal(r, xField), getVal(r, yField)
local rawC = cField and getVal(r, cField) or nil
if xv ~= nil and yv ~= nil then
local xs, ys = tostring(xv), tostring(yv)
if not xMap[xs] then
xCats[#xCats + 1] = xs
xMap[xs] = true
if tonumber(xs) == nil then allXNumeric = false end
end
if not yMap[ys] then
yCats[#yCats + 1] = ys
yMap[ys] = true
if tonumber(ys) == nil then allYNumeric = false end
end
local numC = tonumber(rawC)
if numC ~= nil then
hasNumericColor = true
if numC < cMin then cMin = numC end
if numC > cMax then cMax = numC end
end
cells[#cells + 1] = { xs = xs, ys = ys, val = numC, rawC = rawC }
end
idx = idx + 1
end
else
local xArr = spec.x or {}
local ySeries = spec.y or {}
for j = 1, (xArr.n or #xArr) do
local xs = tostring(xArr[j] or (j - 1))
xCats[j] = xs
xMap[xs] = j
if tonumber(xs) == nil then allXNumeric = false end
end
for i = 1, #ySeries do
local rawTitle = spec.seriesTitles and spec.seriesTitles[i]
local ys = (rawTitle and not match(rawTitle, "^y%d+$")) and tostring(rawTitle) or tostring(i - 1)
yCats[i] = ys
yMap[ys] = i
if tonumber(ys) == nil then allYNumeric = false end
for j = 1, #xCats do
local rawC = ySeries[i][j]
local cv = tonumber(rawC)
if cv ~= nil then
hasNumericColor = true
if cv < cMin then cMin = cv end
if cv > cMax then cMax = cv end
cells[#cells + 1] = { xs = xCats[j], ys = ys, val = cv, rawC = rawC }
end
end
end
end
-- 排序數值座標軸並建立索引映射
if allXNumeric and #xCats > 1 then
table.sort(xCats, function(a, b)
if hm.xReverse then return tonumber(a) > tonumber(b) end
return tonumber(a) < tonumber(b)
end)
end
if allYNumeric and #yCats > 1 then
table.sort(yCats, function(a, b)
if hm.yReverse then return tonumber(a) > tonumber(b) end
return tonumber(a) < tonumber(b)
end)
end
local numXMin, numXMax = 0, max(1, #xCats - 1)
local numYMin, numYMax = 0, max(1, #yCats - 1)
local nx, ny = max(1, #xCats), max(1, #yCats)
if allXNumeric and #xCats > 0 then
numXMin, numXMax = tonumber(xCats[1]) or 0, tonumber(xCats[#xCats]) or 0
if hm.xReverse then numXMin, numXMax = numXMax, numXMin end
local spanX = floor(numXMax - numXMin + 0.5) + 1
if isPixelMode and spanX >= #xCats and spanX <= 200 then
nx = spanX
end
end
if allYNumeric and #yCats > 0 then
numYMin, numYMax = tonumber(yCats[1]) or 0, tonumber(yCats[#yCats]) or 0
if hm.yReverse then numYMin, numYMax = numYMax, numYMin end
local spanY = floor(numYMax - numYMin + 0.5) + 1
if isPixelMode and spanY >= #yCats and spanY <= 200 then
ny = spanY
end
end
for i = 1, #xCats do xMap[xCats[i]] = i end
for i = 1, #yCats do yMap[yCats[i]] = i end
for _, cell in ipairs(cells) do
if isPixelMode and allXNumeric and nx > #xCats then
cell.xi = floor((tonumber(cell.xs) or 0) - numXMin + 0.5) + 1
else
cell.xi = xMap[cell.xs] or 1
end
if isPixelMode and allYNumeric and ny > #yCats then
cell.yi = floor((tonumber(cell.ys) or 0) - numYMin + 0.5) + 1
else
cell.yi = yMap[cell.ys] or 1
end
end
if hm.domainMin ~= nil then cMin = tonumber(hm.domainMin) or cMin end
if hm.domainMax ~= nil then cMax = tonumber(hm.domainMax) or cMax end
if spec.zMin ~= nil then cMin = tonumber(spec.zMin) or cMin end
if spec.zMax ~= nil then cMax = tonumber(spec.zMax) or cMax end
if cMin == math.huge then cMin, cMax = 0, 1 end
if cMax <= cMin then cMax = cMin + 1 end
local cmapKey = spec.colorMap and string.lower(mw.text.trim(tostring(spec.colorMap))) or nil
local rampRange = hm.colorRange
if not rampRange then
if cmapKey and Utils.isColormapName(cmapKey) then
rampRange = {}
for s = 0, 10 do
rampRange[#rampRange + 1] = Utils.sampleColormap(s / 10, cmapKey)
end
else
rampRange = { "#3b4cc0", "#7b9ff9", "#dddddd", "#f29472", "#b40426" }
end
end
local rampDomain = hm.colorDomain
if not rampDomain or #rampDomain < 2 then
rampDomain = {}
local nStops = #rampRange
for s = 1, nStops do
rampDomain[s] = cMin + ((s - 1) / max(1, nStops - 1)) * (cMax - cMin)
end
else
cMin = tonumber(rampDomain[1]) or cMin
cMax = tonumber(rampDomain[#rampDomain]) or cMax
end
local function resolveCellColor(cell)
local rawKey = cell.rawC ~= nil and tostring(cell.rawC) or nil
if isPixelMode and scaleColorMap and rawKey and scaleColorMap[rawKey] then
return scaleColorMap[rawKey]
end
if not isPixelMode and hasNumericColor and cell.val ~= nil then
return interpolateColorRamp(cell.val, rampDomain, rampRange)
end
if scaleColorMap and rawKey and scaleColorMap[rawKey] then
return scaleColorMap[rawKey]
end
if cell.val ~= nil then
return interpolateColorRamp(cell.val, rampDomain, rampRange)
end
if type(cell.rawC) == "string" and cell.rawC ~= "" then
return cell.rawC
end
return hm.fixedColor or "#000000"
end
local buf = {}
local tipBuf = {}
-- 共用熱圖 Tooltip 生成器:<= 100 格使用原始值;> 100 格以 10x10 大網格面積佔比計算加權平均數並渲染加權平均色
local function buildHeatmapHits(originX, originY, areaW, areaH, totalW, totalH)
local hits = {}
if spec.interactive == false or #cells == 0 then return hits end
local totalCells = max(#cells, nx * ny)
local cw = areaW / nx
local ch = areaH / ny
if totalCells <= 100 then
for _, cell in ipairs(cells) do
local tipHtml
if qrTooltip and qrTooltip ~= "" then
tipHtml = esc(qrTooltip)
elseif cell.val ~= nil then
tipHtml = format('<b>(%s, %s)</b>: %s', esc(cell.xs), esc(cell.ys), esc(formatNumber(cell.val, nil, false)))
else
tipHtml = format('<b>(%s, %s)</b>: %s', esc(cell.xs), esc(cell.ys), esc(tostring(cell.rawC or "")))
end
local px = originX + (cell.xi - 1) * cw
local py = originY + (cell._rowIdx - 1) * ch
hits[#hits + 1] = buildRectHit(px, py, cw, ch, totalW, totalH, tipHtml, cell._col)
end
return hits
end
local macroNX = min(10, nx)
local macroNY = min(10, ny)
local macroW = areaW / macroNX
local macroH = areaH / macroNY
local cellGrid = {}
local bins = {}
for cIdx = 1, macroNX do
bins[cIdx] = {}
for rIdx = 1, macroNY do
bins[cIdx][rIdx] = { wSum = 0, valWSum = 0, valCount = 0, fallback = nil }
end
end
for _, cell in ipairs(cells) do
local xi = cell.xi
local rIdxOrig = cell._rowIdx
if not cellGrid[xi] then cellGrid[xi] = {} end
cellGrid[xi][rIdxOrig] = cell
-- 計算原始格在 10x10 大網格中的交集面積佔比作為權重
local x0 = ((xi - 1) / nx) * macroNX
local x1 = (xi / nx) * macroNX
local y0 = ((rIdxOrig - 1) / ny) * macroNY
local y1 = (rIdxOrig / ny) * macroNY
local cStart = max(1, floor(x0 + 1e-9) + 1)
local cEnd = min(macroNX, ceil(x1 - 1e-9))
local rStart = max(1, floor(y0 + 1e-9) + 1)
local rEnd = min(macroNY, ceil(y1 - 1e-9))
for c = cStart, cEnd do
local ow = min(x1, c) - max(x0, c - 1)
if ow > 1e-9 then
for r = rStart, rEnd do
local oh = min(y1, r) - max(y0, r - 1)
if oh > 1e-9 then
local b = bins[c][r]
b.fallback = cell
if cell.val ~= nil then
local w = ow * oh
b.wSum = b.wSum + w
b.valWSum = b.valWSum + cell.val * w
b.valCount = b.valCount + 1
end
end
end
end
end
end
for rIdx = 1, macroNY do
local tRow = max(1, min(ny, floor((rIdx - 0.5) * ny / macroNY) + 1))
for cIdx = 1, macroNX do
local b = bins[cIdx][rIdx]
local tXi = max(1, min(nx, floor((cIdx - 0.5) * nx / macroNX) + 1))
local rep = (cellGrid[tXi] and cellGrid[tXi][tRow]) or b.fallback
if rep then
local tipHtml, macroCol
if qrTooltip and qrTooltip ~= "" then
tipHtml = esc(qrTooltip)
macroCol = rep._col
elseif b.wSum > 0 then
local avgVal = b.valWSum / b.wSum
if isPixelMode and scaleColorMap and not hm.colorRange then
local k0, k1 = tostring(floor(avgVal)), tostring(ceil(avgVal))
if scaleColorMap[k0] and scaleColorMap[k1] then
macroCol = interpolateColorRamp(avgVal, { floor(avgVal), ceil(avgVal) }, { scaleColorMap[k0], scaleColorMap[k1] })
else
macroCol = scaleColorMap[tostring(floor(avgVal + 0.5))] or rep._col
end
else
macroCol = interpolateColorRamp(avgVal, rampDomain, rampRange)
end
local vStr = formatNumber(avgVal, nil, false)
local prefix = (b.valCount > 1) and "約" or ""
tipHtml = format('<b>(%s, %s)</b>: %s%s', esc(rep.xs), esc(rep.ys), prefix, esc(vStr))
elseif rep.val ~= nil then
macroCol = rep._col
tipHtml = format('<b>(%s, %s)</b>: %s', esc(rep.xs), esc(rep.ys), esc(formatNumber(rep.val, nil, false)))
else
macroCol = rep._col
tipHtml = format('<b>(%s, %s)</b>: %s', esc(rep.xs), esc(rep.ys), esc(tostring(rep.rawC or "")))
end
local px = originX + (cIdx - 1) * macroW
local py = originY + (rIdx - 1) * macroH
local hitStr = buildRectHit(px, py, macroW, macroH, totalW, totalH, tipHtml, macroCol)
local rgb = Utils.parseRGB(macroCol)
local bgStyle = format(';background-color:rgba(%d,%d,%d,0.78)">', rgb[1], rgb[2], rgb[3])
hitStr = string.gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-hm-macro"', 1)
hitStr = string.gsub(hitStr, '">', bgStyle, 1)
hits[#hits + 1] = hitStr
end
end
end
return hits
end
-- ========================================================================
-- 模式 A:QR Code / 像素畫模式 (關閉座標軸與圖例時,無縫滿版方格 + 支援 datas 逐格 Tooltip)
-- ========================================================================
if isPixelMode then
local qz = max(8, floor(min(plotW, plotH) * 0.05))
local padL, padR = qz, qz
local padT = qz + (spec.title and 24 or 0)
local padB = qz
local totalW = padL + plotW + padR
local totalH = padT + plotH + padB
local cellW = plotW / nx
local cellH = plotH / ny
local bgCol = "#ffffff"
if scaleColorMap and scaleColorMap["0"] then
bgCol = scaleColorMap["0"]
end
buf[#buf + 1] = format('<rect x="0" y="0" width="%d" height="%d" fill="%s"/>', ceil(totalW), ceil(totalH), bgCol)
buf[#buf + 1] = '<g shape-rendering="crispEdges">'
local function isBgColor(c)
local lc = string.lower(tostring(c or ""))
return lc == string.lower(bgCol) or lc == "#fff" or lc == "#ffffff" or lc == "white"
end
local fgBuf = {}
for _, cell in ipairs(cells) do
local col = resolveCellColor(cell)
local px = padL + (cell.xi - 1) * cellW
local rowIdx = (allYNumeric and not hm.yReverse) and (ny - cell.yi + 1) or cell.yi
local py = padT + (rowIdx - 1) * cellH
cell._col = col
cell._rowIdx = rowIdx
if isBgColor(col) then
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>',
px, py, cellW, cellH, col)
else
fgBuf[#fgBuf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="0.4"/>',
px, py, cellW, cellH, col, col)
end
end
for i = 1, #fgBuf do buf[#buf + 1] = fgBuf[i] end
buf[#buf + 1] = '</g>'
-- QR Code 維持 > 64 格降級為 8x8 並顯示全局外框;一般方格圖則呼叫 buildHeatmapHits
if qrTooltip and qrTooltip ~= "" then
local tipHtml = esc(qrTooltip)
local totalCells = nx * ny
if totalCells > 64 then
local macroN = 8
local macroW = plotW / macroN
local macroH = plotH / macroN
for rIdx = 1, macroN do
for cIdx = 1, macroN do
local px = padL + (cIdx - 1) * macroW
local py = padT + (rIdx - 1) * macroH
local hitStr = buildRectHit(px, py, macroW, macroH, totalW, totalH, tipHtml, nil)
hitStr = string.gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-macro-hit"')
tipBuf[#tipBuf + 1] = hitStr
end
end
else
for rIdx = 1, ny do
for cIdx = 1, nx do
local px = padL + (cIdx - 1) * cellW
local py = padT + (rIdx - 1) * cellH
local hitStr = buildRectHit(px, py, cellW, cellH, totalW, totalH, tipHtml, nil)
hitStr = string.gsub(hitStr, 'class="gr%-hbar"', 'class="gr-hbar gr-macro-hit"')
tipBuf[#tipBuf + 1] = hitStr
end
end
end
local frameLeftPct = (padL / totalW) * 100
local frameTopPct = (padT / totalH) * 100
local frameWPct = (plotW / totalW) * 100
local frameHPct = (plotH / totalH) * 100
tipBuf[#tipBuf + 1] = format('<span class="gr-qr-frame" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%;pointer-events: none;"></span>',
frameLeftPct, frameTopPct, frameWPct, frameHPct)
else
tipBuf = buildHeatmapHits(padL, padT, plotW, plotH, totalW, totalH)
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
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), nil)
end
-- ========================================================================
-- 模式 B:相關性熱圖模式 (四邊封閉外框、內縮留白、右側垂直漸層圖例、單格或加權平均 Tooltip)
-- ========================================================================
local showColorbar = (spec.showLegend ~= false)
local maxYLen = 4
if not allYNumeric then
for _, c in ipairs(yCats) do maxYLen = max(maxYLen, mw.ustring.len(c)) end
end
local padL = (not spec.hideAxes) and (max(46, min(140, maxYLen * 7.5 + 16)) + (spec.yTitle and 18 or 0)) or 14
local cbGap = 12
local cbW = 14
local cbLabelW = 36
local padR = showColorbar and (cbGap + cbW + cbLabelW) or 18
local padT = (spec.title and 32 or 14)
local padB = (not spec.hideAxes) and (28 + (spec.xTitle and 18 or 0)) or 14
local totalW = padL + plotW + padR
local totalH = padT + plotH + padB
if showColorbar then
local stopsBuf = { '<defs><linearGradient id="gr-hm-grad" x1="0%" y1="100%" x2="0%" y2="0%">' }
local nGradStops = 10
for s = 0, nGradStops do
local t = s / nGradStops
local v = cMin + t * (cMax - cMin)
local col = interpolateColorRamp(v, rampDomain, rampRange)
stopsBuf[#stopsBuf + 1] = format('<stop offset="%d%%" stop-color="%s"/>', floor(t * 100 + 0.5), col)
end
stopsBuf[#stopsBuf + 1] = '</linearGradient></defs>'
buf[#buf + 1] = table.concat(stopsBuf)
end
local marginFracX = 0.15 / (nx + 0.30)
local marginFracY = 0.18 / (ny + 0.36)
local gridLeft = padL + plotW * marginFracX
local gridTop = padT + plotH * marginFracY
local gridW = plotW * (1 - 2 * marginFracX)
local gridH = plotH * (1 - 2 * marginFracY)
local cellW = gridW / nx
local cellH = gridH / ny
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#f9f9fb"/>', padL, padT, plotW, plotH)
buf[#buf + 1] = '<g shape-rendering="crispEdges">'
for _, cell in ipairs(cells) do
local col = resolveCellColor(cell)
local px = gridLeft + (cell.xi - 1) * cellW
local rowIdx = (allYNumeric and not hm.yReverse) and (ny - cell.yi + 1) or cell.yi
local py = gridTop + (rowIdx - 1) * cellH
cell._col = col
cell._rowIdx = rowIdx
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="0.4"/>',
px, py, cellW, cellH, col, col)
end
buf[#buf + 1] = '</g>'
tipBuf = buildHeatmapHits(gridLeft, gridTop, gridW, gridH, totalW, totalH)
if not spec.hideAxes then
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="none" stroke="#222222" stroke-width="1.3"/>',
padL, padT, plotW, plotH)
if allXNumeric then
local stepX = (nx > 1) and ((numXMax - numXMin) / (nx - 1)) or 1
if stepX == 0 then stepX = 1 end
local boxXMin = numXMin - 0.65 * stepX
local boxXMax = numXMax + 0.65 * stepX
local nIntervals = 6
for k = 0, nIntervals do
local frac = k / nIntervals
local px = padL + frac * plotW
local val = boxXMin + frac * (boxXMax - boxXMin)
if abs(val) < 1e-9 then val = 0 end
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#222222" stroke-width="1.2"/>',
px, padT + plotH, px, padT + plotH + 5)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="#222222">%s</text>',
px, padT + plotH + 16, esc(formatNumber(val, spec.xAxisFormat, false)))
end
else
for j = 1, #xCats do
local cx = gridLeft + (j - 0.5) * cellW
buf[#buf + 1] = format('<line x1="%.2f" y1="%.1f" x2="%.2f" y2="%.1f" stroke="#222222" stroke-width="1.2"/>',
cx, padT + plotH, cx, padT + plotH + 5)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11px" fill="#222222">%s</text>',
cx, padT + plotH + 16, esc(xCats[j]))
end
end
if allYNumeric then
local stepY = (ny > 1) and ((numYMax - numYMin) / (ny - 1)) or 1
if stepY == 0 then stepY = 1 end
local boxYMin = numYMin - 0.68 * stepY
local boxYMax = numYMax + 0.68 * stepY
local nIntervals = 6
for k = 0, nIntervals do
local frac = k / nIntervals
local py = padT + plotH - frac * plotH
local val = boxYMin + frac * (boxYMax - boxYMin)
if abs(val) < 1e-9 then val = 0 end
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#222222" stroke-width="1.2"/>',
padL - 5, py, padL, py)
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="#222222">%s</text>',
padL - 8, py + 3.8, esc(formatNumber(val, spec.yAxisFormat, false)))
end
else
for i = 1, #yCats do
local cy = gridTop + (i - 0.5) * cellH
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#222222" stroke-width="1.2"/>',
padL - 5, cy, padL, cy)
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="end" font-size="11px" fill="#222222">%s</text>',
padL - 8, cy + 3.8, esc(yCats[i]))
end
end
end
if showColorbar then
local cbX = padL + plotW + cbGap
local cbY = padT
local cbH = plotH
buf[#buf + 1] = format('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="url(#gr-hm-grad)" stroke="#222222" stroke-width="1.2"/>',
cbX, cbY, cbW, cbH)
local nCbIntervals = 4
for k = 0, nCbIntervals do
local frac = k / nCbIntervals
local py = cbY + cbH - frac * cbH
local val = cMin + frac * (cMax - cMin)
if abs(val) < 1e-9 then val = 0 end
buf[#buf + 1] = format('<line x1="%.1f" y1="%.2f" x2="%.1f" y2="%.2f" stroke="#222222" stroke-width="1.2"/>',
cbX + cbW, py, cbX + cbW + 4, py)
buf[#buf + 1] = format('<text x="%.1f" y="%.2f" text-anchor="start" font-size="11px" fill="#222222">%s</text>',
cbX + cbW + 7, py + 3.8, esc(formatNumber(val, nil, false)))
end
end
if spec.title then
buf[#buf + 1] = format('<text x="%.1f" y="20" text-anchor="middle" font-size="14px" font-weight="bold" fill="#222222">%s</text>',
padL + plotW / 2, esc(spec.title))
end
if spec.xTitle and not spec.hideAxes 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 and not spec.hideAxes 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
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-fade" or nil
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end
-- 雷達圖 / 極座標折線圖 / 極座標面積圖渲染器 (Radar / Polar Line / Polar Area Chart)
local function renderRadarChart(spec)
local plotW = max(140, spec.width or 260)
local plotH = max(140, spec.height or 260)
local grey = spec.persistentGrey or "#54595d"
local xArr = spec.x or {}
local ySeries = spec.y or {}
local nSeries = #ySeries
local nAxes = xArr.n or #xArr
for i = 1, nSeries do
local len = ySeries[i].n or #ySeries[i]
if len > nAxes then nAxes = len end
end
nAxes = max(3, nAxes)
local allXNumeric = true
local xNums = {}
local dataXMax = -math.huge
for j = 1, nAxes do
local xv = tonumber(xArr[j])
if xv == nil and xArr[j] ~= nil and xArr[j] ~= false then
allXNumeric = false
end
xNums[j] = xv
if xv and xv > dataXMax then dataXMax = xv end
end
local xUnit = resolvePolarXUnit(spec, allXNumeric, dataXMax, 0, spec.isPolarLine)
local isContinuousPolar = (xUnit == "turn" or xUnit == "radian" or xUnit == "degree")
local defaultStartDeg = (isContinuousPolar or spec.isPolarLine) and 0 or -270
local _, _, toSvgAngle = resolvePolarTransform(spec, defaultStartDeg)
local dataYMin, dataYMax = 0, -math.huge
for i = 1, nSeries do
for j = 1, nAxes do
local v = ySeries[i] and tonumber(ySeries[i][j])
if v then
if v < dataYMin then dataYMin = v end
if v > dataYMax then dataYMax = v end
end
end
end
if dataYMax == -math.huge then dataYMin, dataYMax = 0, 100 end
if dataYMin == dataYMax then dataYMax = dataYMin + 10 end
local yMin = tonumber(spec.yMin) or min(0, dataYMin)
local yMax = tonumber(spec.yMax) or dataYMax
local isPctY = spec.yAxisFormat and (find(spec.yAxisFormat, "%", 1, true) ~= nil or find(spec.yAxisFormat, "‰", 1, true) ~= nil)
local yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, 4, 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 yMax <= yMin then yMax = yMin + 1 end
local maxLblLen = 3
for j = 1, nAxes do
local lbl = tostring(xArr[j] or j)
maxLblLen = max(maxLblLen, mw.ustring.len(lbl))
end
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
if spec.legendTitle then
legW = max(legW, mw.ustring.len(spec.legendTitle) * 8.5 + 20)
end
legW = min(220, max(85, legW))
end
local sidePad = max(44, min(110, maxLblLen * 9 + 16))
local padL = sidePad
local padR = sidePad + legW
local padT = (spec.title and 34 or 16) + 18
local padB = 28
local outerR = min(plotW, plotH) / 2
if spec.outerRadius and spec.outerRadius > 0 then
outerR = min(outerR, spec.outerRadius)
end
local totalW = padL + plotW + padR
local totalH = max(padT + plotH + padB, padT + (spec.showLegend and (nSeries * 19 + 30) or 0) + padB)
local cx = padL + plotW / 2
local cy = padT + plotH / 2
local angScale = (xUnit == "turn") and (2 * pi) or ((xUnit == "degree") and (pi / 180) or 1.0)
local function getAngle(idx)
if isContinuousPolar then
return toSvgAngle((xNums[idx] or 0) * angScale)
end
return toSvgAngle(((idx - 1) / nAxes) * (2 * pi))
end
local function getRadius(val)
local t = max(0, min(1, applyScale(val, yMin, yMax, spec.yScaleType)))
return outerR * t
end
local customState = { defs = {}, map = {}, count = 0, allowImgOverlay = true }
local buf = { "" }
if spec.title then
buf[#buf + 1] = format('<text x="%.1f" y="22" text-anchor="middle" font-size="14px" font-weight="bold" fill="%s">%s</text>', cx, grey, esc(spec.title))
end
local useCircleGrid = isContinuousPolar or (spec.interpolate == "circle" or spec.interpolate == "round")
for tIdx, tk in ipairs(yTicks) do
if tk > yMin + 1e-9 and tk <= yMax + 1e-9 then
local r = getRadius(tk)
local isOuter = (tIdx == #yTicks)
local strokeCol = isOuter and "#b5bac0" or "#e1e3e5"
local sw = isOuter and 1.3 or 1.0
if useCircleGrid then
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="%s" stroke-width="%.1f"/>', cx, cy, r, strokeCol, sw)
else
local ringPts = {}
for j = 1, nAxes do
local ang = getAngle(j)
ringPts[#ringPts + 1] = format("%.2f,%.2f", cx + r * cos(ang), cy + r * sin(ang))
end
buf[#buf + 1] = format('<polygon points="%s" fill="none" stroke="%s" stroke-width="%.1f"/>', table.concat(ringPts, " "), strokeCol, sw)
end
end
end
if isContinuousPolar then
for k = 0, 7 do
local svgAng = toSvgAngle(k * (pi / 4))
local cosA, sinA = cos(svgAng), sin(svgAng)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0d5da" stroke-width="1"/>',
cx, cy, cx + outerR * cosA, cy + outerR * sinA)
if not spec.hideAxes then
local lblR = outerR + 13
local lx, ly = cx + lblR * cosA, cy + lblR * sinA
local lbl = formatPolarRadialTick(k, xUnit, spec.xAxisFormat)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="10px" font-weight="bold" fill="%s">%s</text>',
lx, ly + 3.5, grey, esc(lbl))
end
end
else
for j = 1, nAxes do
local ang = getAngle(j)
local cosA, sinA = cos(ang), sin(ang)
local ex, ey = cx + outerR * cosA, cy + outerR * sinA
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0d5da" stroke-width="1"/>', cx, cy, ex, ey)
if not spec.hideAxes then
local lbl = tostring(xArr[j] or j)
local lblR = outerR + 11
local lx, ly = cx + lblR * cosA, cy + lblR * sinA
local anchor = "middle"
if cosA > 0.18 then anchor = "start"
elseif cosA < -0.18 then anchor = "end" end
local dy = 4
if sinA < -0.55 then dy = -2
elseif sinA > 0.55 then dy = 9 end
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-size="11px" font-weight="bold" fill="%s">%s</text>', lx, ly + dy, anchor, grey, esc(lbl))
end
end
end
if not spec.hideAxes then
for _, tk in ipairs(yTicks) do
if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
local r = getRadius(tk)
local lbl = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-size="10px" fill="#72777d" data-halo="1">%s</text>', cx - 4, cy - r + 3.5, esc(lbl))
end
end
end
local symBuf = {}
local valTextBuf = {}
local tipBuf = {}
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 fillAlpha = spec.isPolarLine and 0 or ((alpha < 1) and alpha or 0.25)
local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or 2.0
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 polyCol = baseCol
if hasSeriesCmap then
local gradId = "gr-rad-cmap-" .. i
customState.defs[#customState.defs + 1] = Utils.buildColormapRadialDef(gradId, sCmap, cx, cy, outerR)
customState.count = (customState.count or 0) + 1
polyCol = "url(#" .. gradId .. ")"
end
local polyPts = {}
for j = 1, nAxes do
local rawY = ySeries[i] and ySeries[i][j]
local yv = (rawY ~= false and rawY ~= nil) and tonumber(rawY) or yMin
local ptCol = hasSeriesCmap and Utils.sampleColormap((yv - cMin) / (cMax - cMin), sCmap) or baseCol
local r = getRadius(yv)
local ang = getAngle(j)
local px, py = cx + r * cos(ang), cy + r * sin(ang)
polyPts[#polyPts + 1] = format("%.2f,%.2f", px, py)
if spec.symbols and spec.symbols.enabled then
local symR = spec.symbols.radii[i] or spec.symbols.radii[1] or 3.2
local shp = spec.symbols.shapes[i] or spec.symbols.shapes[1] or "circle"
local fillC = spec.symbols.noFill and "none" or ptCol
symBuf[#symBuf + 1] = renderSymbol(shp, px, py, symR, fillC, ptCol, spec.symbols.strokeWidth, alpha, customState)
end
if spec.showValues and rawY ~= false and rawY ~= nil then
local sv = spec.showValues
local fSize = tonumber(sv.fontsize) or 10
local fColor = sv.fontcolor or ptCol
local txt = formatNumber(yv, sv.format, spec.yType == "integer")
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>',
px, py - 6, fSize, fColor, esc(txt))
end
if spec.interactive ~= false and rawY ~= false and rawY ~= nil then
local sTitle = spec.seriesTitles[i] or ("y" .. i)
local axisLbl
if xUnit == "turn" then
local hasPct = spec.xAxisFormat and (find(spec.xAxisFormat, "%", 1, true) or find(spec.xAxisFormat, "‰", 1, true))
axisLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. (hasPct and "" or " turn")
elseif xUnit == "radian" then
axisLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. " rad"
elseif xUnit == "degree" then
axisLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. "°"
else
axisLbl = tostring(xArr[j] or j)
end
local valStr = formatNumber(yv, spec.yAxisFormat, spec.yType == "integer")
local tipHtml = (nSeries > 1)
and format('<b>%s</b> (%s): %s', esc(sTitle), esc(axisLbl), esc(valStr))
or format('<b>%s</b>: %s', esc(axisLbl), esc(valStr))
local swSpec = (spec.symbols and spec.symbols.enabled) and {
shape = spec.symbols.shapes[i] or spec.symbols.shapes[1] or "circle",
fill = spec.symbols.noFill and "none" or ptCol,
stroke = ptCol,
strokeWidth = spec.symbols.strokeWidth,
r = spec.symbols.radii[i] or spec.symbols.radii[1] or 3.2
} or nil
tipBuf[#tipBuf + 1] = buildPointHit(px, py, totalW, totalH, tipHtml, ptCol, 16, swSpec)
end
end
if spec.isPolarLine and #polyPts >= 2 then
buf[#buf + 1] = format('<polyline points="%s" fill="none" stroke="%s" stroke-width="%.2f" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="%.2f"/>',
table.concat(polyPts, " "), polyCol, lw, alpha)
elseif #polyPts >= 3 then
buf[#buf + 1] = format('<polygon points="%s" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.2f" stroke-linejoin="round" stroke-opacity="%.2f"/>',
table.concat(polyPts, " "), polyCol, fillAlpha, polyCol, lw, alpha)
end
end
for k = 1, #symBuf do buf[#buf + 1] = symBuf[k] end
for k = 1, #valTextBuf do buf[#buf + 1] = valTextBuf[k] end
if spec.showLegend and nSeries > 0 then
local legX = padL + plotW + sidePad - 10
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-rad-leg-cmap-" .. i
customState.defs[#customState.defs + 1] = Utils.buildColormapGradientDef(gradId, sCmap, "0%", "0%", "100%", "0%")
customState.count = (customState.count or 0) + 1
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" fill-opacity="0.4" stroke="%s" stroke-width="1.5"/>', legX, iy, baseCol, 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
if customState.count > 0 then
buf[1] = format('<defs>%s</defs>', table.concat(customState.defs))
end
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-radial" or nil
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
-- 極座標長條圖 / 直方圖 / 南丁格爾玫瑰圖渲染器 (Polar Bar / Polar Histogram / Rose Chart)
local function renderPolarBarChart(spec)
local plotW = max(140, spec.width or 280)
local plotH = max(140, spec.height or 280)
local grey = spec.persistentGrey or "#54595d"
local stacked = spec.stacked
local reverseY = (spec.reverseY == true)
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
nPoints = max(1, nPoints)
local hasExplicitZ = (type(zSeries) == "table" and #zSeries > 0)
local allXNumeric = true
local xNums = {}
local dataXMin, dataXMax = math.huge, -math.huge
for j = 1, nPoints do
local xv = tonumber(xArr[j])
if xv == nil and xArr[j] ~= nil and xArr[j] ~= false then
allXNumeric = false
end
xNums[j] = xv
if xv then
if xv < dataXMin then dataXMin = xv end
if xv > dataXMax then dataXMax = xv end
end
end
local maxZWidth = 0
if hasExplicitZ then
for i = 1, #zSeries do
local zArr = zSeries[i] or {}
for j = 1, nPoints do
local zw = tonumber(zArr[j])
if zw and abs(zw) > maxZWidth then maxZWidth = abs(zw) end
end
end
end
local xUnit = resolvePolarXUnit(spec, allXNumeric, dataXMax, maxZWidth, hasExplicitZ or spec.polarHist)
local isContinuousPolar = (xUnit == "turn" or xUnit == "radian" or xUnit == "degree")
local defaultStartDeg = isContinuousPolar and 0 or -270
local _, _, toSvgAngle = resolvePolarTransform(spec, defaultStartDeg)
local stackedY0, stackedY1 = {}, {}
local dataYMin, dataYMax = 0, -math.huge
for i = 1, nSeries do
stackedY0[i] = {}
stackedY1[i] = {}
end
for j = 1, nPoints do
local posSum = 0
for i = 1, nSeries do
local rawV = ySeries[i] and ySeries[i][j]
local v = (rawV ~= false and rawV ~= nil) and tonumber(rawV) or nil
if v ~= nil then
if stacked then
stackedY0[i][j] = posSum
posSum = posSum + max(0, v)
stackedY1[i][j] = posSum
else
stackedY0[i][j] = 0
stackedY1[i][j] = v
end
if stackedY0[i][j] < dataYMin then dataYMin = stackedY0[i][j] end
if stackedY1[i][j] < dataYMin then dataYMin = stackedY1[i][j] end
if stackedY1[i][j] > dataYMax then dataYMax = stackedY1[i][j] end
end
end
end
if dataYMax == -math.huge then dataYMin, dataYMax = 0, 10 end
if dataYMin == dataYMax then dataYMax = dataYMin + 10 end
local yMin = tonumber(spec.yMin) or min(0, dataYMin)
local yMax = tonumber(spec.yMax) or dataYMax
local isPctY = spec.yAxisFormat and (find(spec.yAxisFormat, "%", 1, true) ~= nil or find(spec.yAxisFormat, "‰", 1, true) ~= nil)
local yTicks, niceYMin, niceYMax = computeNiceTicks(yMin, yMax, 4, 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 yMax <= yMin then yMax = yMin + 1 end
local cMin = spec.domainMin or yMin
local cMax = spec.domainMax or yMax
if cMax <= cMin then cMax = cMin + 1 end
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 + 36)
end
if spec.legendTitle then
legW = max(legW, mw.ustring.len(spec.legendTitle) * 8.5 + 20)
end
legW = min(220, max(85, legW))
end
local sidePad = spec.hideAxes and 14 or 42
local padL = sidePad
local padR = sidePad + legW
local padT = (spec.title and 32 or 14) + (spec.hideAxes and 0 or 12)
local padB = spec.hideAxes and 14 or 24
local outerR = min(plotW, plotH) / 2
if spec.outerRadius and spec.outerRadius > 0 then
outerR = min(outerR, spec.outerRadius)
end
local innerR = max(0, min(outerR - 6, tonumber(spec.innerRadius) or 0))
local totalW = padL + plotW + padR
local totalH = max(padT + plotH + padB, padT + (spec.showLegend and (nSeries * 19 + 30) or 0) + padB)
local cx = padL + plotW / 2
local cy = padT + plotH / 2
local function mapR(val)
local t = max(0, min(1, applyScale(val, yMin, yMax, spec.yScaleType)))
if reverseY then
return outerR - t * (outerR - innerR)
end
return innerR + t * (outerR - innerR)
end
local function buildSectorPath(r0, r1, svgA0, svgA1)
local rLo, rHi = min(r0, r1), max(r0, r1)
local dAng = svgA1 - svgA0
if dAng <= 1e-6 or rHi <= 0.2 then return nil end
if dAng >= 2 * pi - 1e-4 then
if rLo > 0.4 then
return format("M %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f M %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f Z",
cx, cy - rHi, rHi, rHi, cx, cy + rHi, rHi, rHi, cx, cy - rHi,
cx, cy - rLo, rLo, rLo, cx, cy + rLo, rLo, rLo, cx, cy - rLo)
end
return format("M %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f Z",
cx, cy - rHi, rHi, rHi, cx, cy + rHi, rHi, rHi, cx, cy - rHi)
end
local largeArc = (dAng > pi) and 1 or 0
local x1, y1 = cx + rHi * cos(svgA0), cy + rHi * sin(svgA0)
local x2, y2 = cx + rHi * cos(svgA1), cy + rHi * sin(svgA1)
if rLo > 0.4 then
local ix1, iy1 = cx + rLo * cos(svgA1), cy + rLo * sin(svgA1)
local ix2, iy2 = cx + rLo * cos(svgA0), cy + rLo * sin(svgA0)
return format("M %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,0 %.2f,%.2f Z",
x1, y1, rHi, rHi, largeArc, x2, y2, ix1, iy1, rLo, rLo, largeArc, ix2, iy2)
else
return format("M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,1 %.2f,%.2f Z",
cx, cy, x1, y1, rHi, rHi, largeArc, x2, y2)
end
end
local defsBuf = {}
local buf = { "" }
-- 1. 繪製極座標同心圓格線與放射軸線
if innerR > 0.5 then
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#888888" stroke-width="1"/>', cx, cy, innerR)
end
for _, tk in ipairs(yTicks) do
if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
local r = mapR(tk)
if r > 0.5 and abs(r - outerR) > 1.0 then
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#b8bdc2" stroke-width="0.9"/>', cx, cy, r)
end
end
end
if isContinuousPolar then
-- 連續角度/弧度/轉數模式:從 startAngle 起算,依旋轉方向繪製 8 條極座標放射軸線
for k = 0, 7 do
local svgAng = toSvgAngle(k * (pi / 4))
local x0, y0 = cx + innerR * cos(svgAng), cy + innerR * sin(svgAng)
local x1, y1 = cx + outerR * cos(svgAng), cy + outerR * sin(svgAng)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#b8bdc2" stroke-width="0.9"/>', x0, y0, x1, y1)
if not spec.hideAxes then
local lblR = outerR + 14
local lx, ly = cx + lblR * cos(svgAng), cy + lblR * sin(svgAng)
local lbl = formatPolarRadialTick(k, xUnit, spec.xAxisFormat)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="10px" fill="%s">%s</text>', lx, ly + 3.5, grey, esc(lbl))
end
end
else
-- 離散類別模式:從 startAngle 起算,依類別數量均分放射軸線
for j = 1, nPoints do
local svgAng = toSvgAngle(((j - 1) / nPoints) * (2 * pi))
local x0, y0 = cx + innerR * cos(svgAng), cy + innerR * sin(svgAng)
local x1, y1 = cx + outerR * cos(svgAng), cy + outerR * sin(svgAng)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0d5da" stroke-width="0.9"/>', x0, y0, x1, y1)
if not spec.hideAxes then
local midBandAng = toSvgAngle(((j - 0.5) / nPoints) * (2 * pi))
local lblR = outerR + 13
local lx, ly = cx + lblR * cos(midBandAng), cy + lblR * sin(midBandAng)
local cosA = cos(midBandAng)
local anchor = (cosA > 0.2) and "start" or ((cosA < -0.2) and "end" or "middle")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" font-size="11px" font-weight="bold" fill="%s">%s</text>',
lx, ly + 3.8, anchor, grey, esc(tostring(xArr[j] or j)))
end
end
end
-- 2. 計算各扇形角度區間並繪製扇形長條
local defaultPad = (spec.polarHist or hasExplicitZ) and 0.0 or ((nSeries > 1 and not stacked) and 0.22 or 0.16)
local padRatio = max(0, min(0.85, tonumber(spec.barPadding) or defaultPad))
local grpPadRatio = max(0, min(0.6, tonumber(spec.groupPadding) or 0.0))
local sectorBuf = {}
local valTextBuf = {}
local tipBuf = {}
local MAX_TIPS = 100
local totalValid = 0
for i = 1, nSeries do
for j = 1, nPoints do
if stackedY1[i][j] ~= nil then totalValid = totalValid + 1 end
end
end
local tipStep = (totalValid > MAX_TIPS) and (totalValid / MAX_TIPS) or 1
local curValidIdx = 0
local nextTipIdx = 1
local sortedX = nil
if isContinuousPolar and not hasExplicitZ and nPoints > 1 then
sortedX = {}
for j = 1, nPoints do
if xNums[j] then sortedX[#sortedX + 1] = xNums[j] end
end
table.sort(sortedX)
end
local isEdgePolar = (spec.barAlign ~= "center")
local angScale = (xUnit == "turn") and (2 * pi) or ((xUnit == "degree") and (pi / 180) or 1.0)
for j = 1, nPoints do
local bandRad0, bandRad1
if isContinuousPolar then
local cAng = (xNums[j] or 0) * angScale
local wAng
if hasExplicitZ then
local rawZ = (zSeries[1] and tonumber(zSeries[1][j])) or 0.3
wAng = abs(rawZ) * angScale
elseif sortedX and #sortedX > 1 then
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
wAng = ((minDiff < math.huge) and minDiff or ((2 * pi / angScale) / nPoints)) * angScale
else
wAng = (2 * pi) / nPoints
end
local rawStart = isEdgePolar and cAng or (cAng - wAng * 0.5)
local rawEnd = isEdgePolar and (cAng + wAng) or (cAng + wAng * 0.5)
local midAng = (rawStart + rawEnd) * 0.5
local usableW = wAng * (1 - padRatio)
bandRad0 = midAng - usableW * 0.5
bandRad1 = midAng + usableW * 0.5
else
local fullBand = (2 * pi) / nPoints
local usableW = fullBand * (1 - padRatio)
local centerRad = (j - 0.5) * fullBand
bandRad0 = centerRad - usableW * 0.5
bandRad1 = centerRad + usableW * 0.5
end
local bandSpan = bandRad1 - bandRad0
local nSub = stacked and 1 or nSeries
local subStep = bandSpan / nSub
local subW = subStep * (1 - (nSub > 1 and grpPadRatio or 0))
for i = 1, nSeries do
local y0v = stackedY0[i][j]
local y1v = stackedY1[i][j]
if y1v ~= nil then
curValidIdx = curValidIdx + 1
local rawVal = tonumber(ySeries[i][j]) or y1v
local zVal = hasExplicitZ and ((zSeries[i] and tonumber(zSeries[i][j])) or (zSeries[1] and tonumber(zSeries[1][j]))) or nil
local rStart, rEnd = bandRad0, bandRad1
if not stacked and nSeries > 1 then
if hasExplicitZ and zSeries[i] and tonumber(zSeries[i][j]) and isContinuousPolar then
local cAng = (xNums[j] or 0) * angScale
local fullW = abs(tonumber(zSeries[i][j])) * angScale
local rawStart = isEdgePolar and cAng or (cAng - fullW * 0.5)
local midAng = rawStart + fullW * 0.5
local wAng = fullW * (1 - padRatio)
local subSpan = wAng / nSeries
rStart = (midAng - wAng * 0.5) + (i - 1) * subSpan
rEnd = rStart + subSpan * (1 - grpPadRatio)
else
rStart = bandRad0 + (i - 1) * subStep + (subStep - subW) * 0.5
rEnd = rStart + subW
end
end
local a0, a1 = toSvgAngle(rStart), toSvgAngle(rEnd)
local sA0, sA1 = min(a0, a1), max(a0, a1)
local r0, r1 = mapR(y0v), mapR(y1v)
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 spec.colors[(i - 1) % #spec.colors + 1]
local col
if hasSeriesCmap then
col = Utils.sampleColormap((rawVal - cMin) / (cMax - cMin), sCmap)
elseif nSeries == 1 and not spec.colors[2] then
col = baseCol
elseif nSeries == 1 then
col = spec.colors[(j - 1) % #spec.colors + 1]
else
col = baseCol
end
local alpha = spec.alphas[(i - 1) % #spec.alphas + 1] or 0.82
if alpha >= 1.0 and isContinuousPolar then alpha = 0.82 end
local lw = (spec.linewidths and spec.linewidths[i]) or spec.linewidth or 1.4
local strokeCol = (lw > 0) and "#333333" or "none"
local pathD = buildSectorPath(r0, r1, sA0, sA1)
if pathD then
sectorBuf[#sectorBuf + 1] = format('<path d="%s" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="%.2f" stroke-linejoin="round"/>',
pathD, col, alpha, strokeCol, lw)
end
local midA = (sA0 + sA1) * 0.5
if spec.showValues then
local sv = spec.showValues
local fSize = tonumber(sv.fontsize) or 10
local fColor = sv.fontcolor or grey
local lblR = max(r0, r1) + (tonumber(sv.offset) or 10)
local lx, ly = cx + lblR * cos(midA), cy + lblR * sin(midA)
local txt = formatNumber(rawVal, sv.format or spec.yAxisFormat, spec.yType == "integer")
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>',
lx, ly + 3.5, fSize, fColor, esc(txt))
end
if spec.interactive ~= false and #tipBuf < MAX_TIPS and curValidIdx >= floor(nextTipIdx + 1e-9) then
nextTipIdx = nextTipIdx + tipStep
local xLbl, zSuffix = "", ""
local hasPctFmt = spec.xAxisFormat and (find(spec.xAxisFormat, "%", 1, true) or find(spec.xAxisFormat, "‰", 1, true))
local unitSuffix = (xUnit == "degree") and "°" or ((xUnit == "radian") and " rad" or ((xUnit == "turn" and not hasPctFmt) and " turn" or ""))
if isContinuousPolar then
xLbl = formatNumber(xNums[j], spec.xAxisFormat, false) .. unitSuffix
else
xLbl = tostring(xArr[j] or j)
end
if zVal then
local zFmtStr = hasPctFmt and formatNumber(zVal, spec.xAxisFormat, false) or formatNumber(zVal, nil, false)
zSuffix = format(' (width: %s%s)', esc(zFmtStr), unitSuffix)
end
local vStr = formatNumber(rawVal, 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%s', esc(sTitle), esc(xLbl), esc(vStr), zSuffix)
or format('<b>%s</b>: %s%s', esc(xLbl), esc(vStr), zSuffix)
tipBuf[#tipBuf + 1] = buildSectorHit(cx, cy, r0, r1, sA0, sA1, totalW, totalH, tipHtml, col)
end
end
end
end
for k = 1, #sectorBuf do buf[#buf + 1] = sectorBuf[k] end
-- 3. 繪製外圓主框線與徑向刻度文字
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="none" stroke="#222222" stroke-width="1.6"/>', cx, cy, outerR)
if not spec.hideAxes then
for _, tk in ipairs(yTicks) do
if tk >= yMin - 1e-9 and tk <= yMax + 1e-9 then
local r = mapR(tk)
local lbl = formatNumber(tk, spec.yAxisFormat, spec.yType == "integer")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="end" font-size="10px" fill="#54595d" data-halo="1">%s</text>',
cx - 4, cy - r + 3.5, esc(lbl))
end
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>', cx, grey, esc(spec.title))
end
if spec.showLegend and nSeries > 0 then
local legX = padL + plotW + sidePad - 10
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-pb-leg-cmap-" .. i
defsBuf[#defsBuf + 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" stroke="#333333" stroke-width="0.8"/>', 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
if #defsBuf > 0 then
buf[1] = format('<defs>%s</defs>', table.concat(defsBuf))
end
local animCls = (spec.animate ~= false) and "gr-anim gr-anim-radial" or nil
TrackingCategory.append('有SVG圖表的頁面')
return finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-chart-svg', false, table.concat(tipBuf), animCls)
end
p.renderPieChart = renderPieChart
p.renderRangedRectChart = renderRangedRectChart
p.renderBoxPlotChart = renderBoxPlotChart
p.renderGanttChart = renderGanttChart
p.renderHeatmapChart = renderHeatmapChart
p.renderRadarChart = renderRadarChart
p.renderPolarBarChart = renderPolarBarChart
return p