模組:Graph/RenderUtils
外观
-- Module:Graph/RenderUtils
-- 圖表幾何運算、數值/日期格式化、自訂符號正規化與 HTML 疊層輸出工具庫
local p = {}
local format, sub, gsub, match, gmatch, find = string.format, string.sub, string.gsub, string.match, string.gmatch, string.find
local floor, ceil, abs, max, min = math.floor, math.ceil, math.abs, math.max, math.min
local log, log10, sqrt = math.log, math.log10, math.sqrt
local CSS_COLORS = {
white = {255, 255, 255}, black = {0, 0, 0}, silver = {192, 192, 192},
gray = {128, 128, 128}, grey = {128, 128, 128}, red = {255, 0, 0},
darkred = {139, 0, 0}, green = {0, 128, 0}, blue = {0, 0, 255},
darkblue = {0, 0, 139}, lightblue = {173, 216, 230}, steelblue = {70, 130, 180},
yellow = {255, 255, 0}, gold = {255, 215, 0}, orange = {255, 165, 0},
purple = {128, 0, 128}, pink = {255, 192, 203}, brown = {165, 42, 42},
cyan = {0, 255, 255}, teal = {0, 128, 128}, navy = {0, 0, 128}
}
local MONTH_ABBR = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }
local MONTH_FULL = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }
-- 轉義 XML/HTML 特殊字元,但保護 MediaWiki 繁簡轉換 Strip Marker 不被 unstrip 抹除
function p.esc(s)
local str = tostring(s or "")
str = gsub(str, "\127'\"`UNIQ", "__MW_SM_PRE__UNIQ")
str = gsub(str, "QINU`\"'\127", "QINU__MW_SM_SUF__")
return mw.text.encode(str)
end
-- 還原 Strip Marker 供 HTML 疊層參與 MediaWiki 後期繁簡轉換管線
function p.restoreStripMarkers(s)
if type(s) ~= "string" then return "" end
s = gsub(s, "__MW_SM_PRE__UNIQ", "\127'\"`UNIQ")
s = gsub(s, "QINU__MW_SM_SUF__", "QINU`\"'\127")
return s
end
-- 新增千分位逗號
local function addCommas(numStr)
local sign, intPart, decPart = match(numStr, "^([%+%-]?)(%d+)(%.?%d*)$")
if not intPart then return numStr end
local rev = string.reverse(intPart)
rev = gsub(rev, "(%d%d%d)", "%1,")
local res = string.reverse(rev)
res = gsub(res, "^,", "")
return sign .. res .. decPart
end
-- 支援 D3 數字格式化 (含千分位 ','、小數位 '.2f'、百分比 '%'、千分比 '‰'、科學記號 's')
function p.formatNumber(val, fmt, isInt)
local n = tonumber(val)
if not n then return tostring(val or "") end
if abs(n) < 1e-12 then n = 0 end
if not fmt or fmt == "" then
if isInt or abs(n - floor(n + 0.5)) < 1e-7 then
return addCommas(tostring(floor(n + 0.5)))
end
return addCommas(format("%.2f", n):gsub("%.?0+$", ""))
end
local useComma = find(fmt, ",", 1, true) ~= nil
local isPct = find(fmt, "%", 1, true) ~= nil
local isPermille = find(fmt, "‰", 1, true) ~= nil
local isSI = find(fmt, "s", 1, true) ~= nil
local prec = tonumber(match(fmt, "%.(%d+)"))
if isPct then
n = n * 100
elseif isPermille then
n = n * 1000
end
if abs(n) < 1e-12 then n = 0 end
local suffix = ""
if isSI and n ~= 0 then
local absN = abs(n)
if absN >= 1e9 then n = n / 1e9; suffix = "G"
elseif absN >= 1e6 then n = n / 1e6; suffix = "M"
elseif absN >= 1e3 then n = n / 1e3; suffix = "k" end
end
local out
if prec then
out = format("%." .. prec .. "f", n)
elseif (isInt and not isPct and not isPermille) or abs(n - floor(n + 0.5)) < 1e-7 then
out = tostring(floor(n + 0.5))
else
out = format("%.2f", n):gsub("%.?0+$", "")
end
if useComma then out = addCommas(out) end
if isPct then
out = out .. "%"
elseif isPermille then
out = out .. "‰"
end
return out .. suffix
end
-- 將 ISO 日期字串或年份轉為小數年份 (如 "2021-07-01" -> 2021.497)
function p.parseDateValue(v)
if v == nil or v == false then return nil end
if type(v) == "number" then
if v > 1e8 then return 1970 + v / 31557600000 end
return v
end
local s = mw.text.trim(tostring(v))
local y, m, d = match(s, "^(%d%d%d%d)[%-%/](%d%d?)[%-%/](%d%d?)")
if y then
return tonumber(y) + (tonumber(m) - 1) / 12 + (tonumber(d) - 1) / 365.25
end
y, m = match(s, "^(%d%d%d%d)[%-%/](%d%d?)$")
if y then
return tonumber(y) + (tonumber(m) - 1) / 12
end
return tonumber(s)
end
-- 將小數年份格式化為日期字串 (支援 %Y, %y, %b, %B, %m, %d)
function p.formatDateValue(decYear, fmt)
local y = floor(decYear + 1e-6)
local rem = max(0, decYear - y)
local m = min(12, max(1, floor(rem * 12 + 0.5) + 1))
local d = min(28, max(1, floor((rem * 12 - (m - 1)) * 30 + 0.5) + 1))
if not fmt or fmt == "" then
if rem < 0.02 or rem > 0.98 then
return tostring(floor(decYear + 0.5))
end
return format("%04d-%02d", y, m)
end
local out = fmt
out = gsub(out, "%%Y", format("%04d", y))
out = gsub(out, "%%y", format("%02d", y % 100))
out = gsub(out, "%%B", MONTH_FULL[m] or "")
out = gsub(out, "%%b", MONTH_ABBR[m] or "")
out = gsub(out, "%%m", format("%02d", m))
out = gsub(out, "%%d", format("%02d", d))
return out
end
-- 比例尺映射 (linear, log, sqrt, pow)
function p.applyScale(val, minV, maxV, scaleType)
if maxV <= minV then return 0.5 end
local sType = scaleType and string.lower(tostring(scaleType)) or "linear"
if sType == "log" then
local lMin = log10(max(1e-9, minV))
local lMax = log10(max(1e-8, maxV))
if lMax <= lMin then return 0.5 end
return (log10(max(1e-9, val)) - lMin) / (lMax - lMin)
elseif sType == "sqrt" then
local sMin = sqrt(max(0, minV))
local sMax = sqrt(max(1e-9, maxV))
return (sqrt(max(0, val)) - sMin) / (sMax - sMin)
elseif sub(sType, 1, 3) == "pow" then
local expN = tonumber(match(sType, "([%d%.]+)")) or 2
local pMin = (minV >= 0 and 1 or -1) * (abs(minV) ^ expN)
local pMax = (maxV >= 0 and 1 or -1) * (abs(maxV) ^ expN)
local pVal = (val >= 0 and 1 or -1) * (abs(val) ^ expN)
return (pVal - pMin) / (pMax - pMin)
end
return (val - minV) / (maxV - minV)
end
-- 計算漂亮座標軸刻度 (Nice Ticks)
function p.computeNiceTicks(minV, maxV, targetCount, scaleType, isInteger)
targetCount = max(2, tonumber(targetCount) or 5)
if minV == maxV then
minV = minV - 1
maxV = maxV + 1
end
local sType = scaleType and string.lower(tostring(scaleType)) or "linear"
if sType == "log" and minV > 0 and maxV > minV then
local pMin = floor(log10(minV) + 1e-9)
local pMax = ceil(log10(maxV) - 1e-9)
local ticks = {}
for expP = pMin, pMax do
ticks[#ticks + 1] = 10 ^ expP
end
if #ticks >= 2 then
return ticks, min(minV, ticks[1]), max(maxV, ticks[#ticks])
end
end
local span = maxV - minV
local rawStep = span / targetCount
local mag = 10 ^ floor(log10(max(1e-12, rawStep)))
local norm = rawStep / mag
local step
if norm <= 1.5 then step = 1 * mag
elseif norm <= 3.0 then step = 2 * mag
elseif norm <= 7.0 then step = 5 * mag
else step = 10 * mag end
if isInteger and step < 1 then step = 1 end
local niceMin = floor(minV / step + 1e-9) * step
local niceMax = ceil(maxV / step - 1e-9) * step
local ticks = {}
local cur = niceMin
local guard = 0
while cur <= niceMax + step * 0.001 and guard < 100 do
if abs(cur) < 1e-11 then cur = 0 end
ticks[#ticks + 1] = cur
cur = cur + step
guard = guard + 1
end
return ticks, niceMin, niceMax
end
-- 判斷字串是否為合法的單一顏色表示法 (支援 CSS 色名、#rgb、#rrggbb、#aarrggbb、rgb()/rgba())
function p.isValidColorToken(cStr)
if type(cStr) ~= "string" then return false end
local s = string.lower(mw.text.trim(cStr))
if s == "" then return false end
if CSS_COLORS[s] then return true end
if match(s, "^#%x%x%x$") or match(s, "^#%x%x%x%x%x%x$") or match(s, "^#%x%x%x%x%x%x%x%x$") then
return true
end
if match(s, "^rgba?%s*%([%d%s,%.%%]+%)$") then
return true
end
return false
end
-- 解析顏色字串為 {r, g, b}
function p.parseRGB(cStr)
if not cStr then return {128, 128, 128} end
cStr = string.lower(mw.text.trim(tostring(cStr)))
if CSS_COLORS[cStr] then return CSS_COLORS[cStr] end
local r, g, b = match(cStr, "^#(%x%x)(%x%x)(%x%x)$")
if r then return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) } end
local _, ar, ag, ab = match(cStr, "^#(%x%x)(%x%x)(%x%x)(%x%x)$")
if ar then return { tonumber(ar, 16), tonumber(ag, 16), tonumber(ab, 16) } end
local sr, sg, sb = match(cStr, "^#(%x)(%x)(%x)$")
if sr then return { tonumber(sr .. sr, 16), tonumber(sg .. sg, 16), tonumber(sb .. sb, 16) } end
local rr, rg, rb = match(cStr, "^rgba?%s*%(%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)")
if rr then return { tonumber(rr), tonumber(rg), tonumber(rb) } end
return {128, 128, 128}
end
function p.rgbToHex(r, g, b)
return format("#%02x%02x%02x",
max(0, min(255, floor(r + 0.5))),
max(0, min(255, floor(g + 0.5))),
max(0, min(255, floor(b + 0.5)))
)
end
-- 多錨點線性色階插值 (供歷史氣溫圖與二維熱圖使用)
function p.interpolateColorRamp(val, domain, range)
if type(domain) ~= "table" or type(range) ~= "table" or #domain == 0 or #range == 0 then
return "#3182bd"
end
local v = tonumber(val) or 0
if v <= tonumber(domain[1]) then return range[1] end
local n = min(#domain, #range)
if v >= tonumber(domain[n]) then return range[n] end
for i = 1, n - 1 do
local d0, d1 = tonumber(domain[i]), tonumber(domain[i + 1])
if d0 and d1 and v >= d0 and v <= d1 then
local t = (d1 > d0) and ((v - d0) / (d1 - d0)) or 0
local c0 = p.parseRGB(range[i])
local c1 = p.parseRGB(range[i + 1])
return p.rgbToHex(
c0[1] + (c1[1] - c0[1]) * t,
c0[2] + (c1[2] - c0[2]) * t,
c0[3] + (c1[3] - c0[3]) * t
)
end
end
return range[n]
end
-- 科學視覺化連續色階表
p.COLORMAPS = {
viridis = {
{ 68, 1, 84 }, { 59, 82, 139 }, { 33, 145, 140 }, { 94, 201, 98 }, { 253, 231, 37 }
},
coolwarm = {
{ 59, 76, 192 }, { 141, 176, 254 }, { 221, 221, 221 }, { 244, 154, 123 }, { 180, 4, 38 }
},
plasma = {
{ 13, 8, 135 }, { 126, 3, 168 }, { 204, 71, 120 }, { 248, 149, 64 }, { 240, 249, 33 }
},
inferno = {
{ 0, 0, 4 }, { 87, 16, 110 }, { 188, 55, 84 }, { 249, 142, 9 }, { 252, 255, 164 }
},
magma = {
{ 0, 0, 4 }, { 81, 18, 124 }, { 183, 55, 121 }, { 251, 136, 97 }, { 252, 253, 191 }
},
rainbow = {
{ 48, 18, 59 }, { 40, 188, 235 }, { 164, 252, 60 }, { 251, 126, 33 }, { 122, 4, 3 }
},
gray = {
{ 0, 0, 0 }, { 255, 255, 255 }
},
greys = {
{ 255, 255, 255 }, { 0, 0, 0 }
}
}
-- 吃進 CPython Matplotlib _cm._jet_data 原生分段線性公式
local function evalJetRGB(t)
local rf, gf, bf
if t <= 0.35 then rf = 0.0
elseif t <= 0.66 then rf = (t - 0.35) / 0.31
elseif t <= 0.89 then rf = 1.0
else rf = 1.0 - 0.5 * (t - 0.89) / 0.11 end
if t <= 0.125 then gf = 0.0
elseif t <= 0.375 then gf = (t - 0.125) / 0.25
elseif t <= 0.64 then gf = 1.0
elseif t <= 0.91 then gf = 1.0 - (t - 0.64) / 0.27
else gf = 0.0 end
if t <= 0.11 then bf = 0.5 + 0.5 * (t / 0.11)
elseif t <= 0.34 then bf = 1.0
elseif t <= 0.65 then bf = 1.0 - (t - 0.34) / 0.31
else bf = 0.0 end
return rf * 255, gf * 255, bf * 255
end
local customCmapCache = {}
-- 解析內建或自訂色階規格 (支援漸層式 #000:#f00:#fff 與離散式 discrete:#000:#f00:#fff 或 #000;#f00;#fff)
function p.parseColormapSpec(rawName)
if type(rawName) ~= "string" then return nil end
local s = mw.text.trim(rawName)
if s == "" then return nil end
if customCmapCache[s] ~= nil then
return customCmapCache[s] or nil
end
local body = s
local isDiscrete = false
local prefix, rest = match(body, "^([%a]+)%s*:%s*(.+)$")
if prefix then
local lp = string.lower(prefix)
if lp == "discrete" or lp == "step" or lp == "steps" then
isDiscrete = true
body = mw.text.trim(rest)
elseif lp == "gradient" or lp == "continuous" or lp == "linear" then
body = mw.text.trim(rest)
end
end
local bracketInner = match(body, "^%[%s*(.-)%s*%]$")
if bracketInner then body = bracketInner end
local cname = string.lower(body)
if cname == "jet" then
local spec = { kind = "jet", isDiscrete = isDiscrete, nSteps = 7 }
customCmapCache[s] = spec
return spec
elseif p.COLORMAPS[cname] then
local stops = p.COLORMAPS[cname]
local spec = { kind = "stops", stops = stops, isDiscrete = isDiscrete, nSteps = #stops }
customCmapCache[s] = spec
return spec
end
local sep = nil
if find(body, ";", 1, true) then
sep = ";"
isDiscrete = true
elseif find(body, ":", 1, true) then
sep = ":"
elseif find(body, ",", 1, true) then
sep = ","
end
if sep then
local parts = mw.text.split(body, "%s*" .. sep .. "%s*")
if #parts >= 2 then
local stops = {}
local allValid = true
for i = 1, #parts do
local tk = mw.text.trim(parts[i])
if p.isValidColorToken(tk) then
stops[#stops + 1] = p.parseRGB(tk)
else
allValid = false
break
end
end
if allValid and #stops >= 2 then
local spec = { kind = "stops", stops = stops, isDiscrete = isDiscrete, nSteps = #stops }
customCmapCache[s] = spec
return spec
end
end
end
customCmapCache[s] = false
return nil
end
function p.isColormapName(name)
return p.parseColormapSpec(name) ~= nil
end
-- 將正規化數值 t ∈ [0, 1] 映射為色階 Hex (同時支援內建與自訂之漸層式/離散式色階)
function p.sampleColormap(t, mapName)
t = max(0, min(1, tonumber(t) or 0))
local spec = p.parseColormapSpec(mapName) or p.parseColormapSpec("viridis")
if spec.isDiscrete then
local n = max(2, spec.nSteps or (spec.stops and #spec.stops) or 5)
local bucket = min(n, max(1, floor(t * n) + 1))
if spec.kind == "jet" then
local bt = (bucket - 1) / max(1, n - 1)
return p.rgbToHex(evalJetRGB(bt))
end
local stops = spec.stops
if bucket <= #stops then
local c = stops[bucket]
return p.rgbToHex(c[1], c[2], c[3])
end
end
if spec.kind == "jet" then
return p.rgbToHex(evalJetRGB(t))
end
local stops = spec.stops or p.COLORMAPS.viridis
local n = #stops
local scaled = t * (n - 1)
local idx = min(n - 1, floor(scaled) + 1)
local frac = scaled - (idx - 1)
local c0, c1 = stops[idx], stops[idx + 1]
return p.rgbToHex(
c0[1] + (c1[1] - c0[1]) * frac,
c0[2] + (c1[2] - c0[2]) * frac,
c0[3] + (c1[3] - c0[3]) * frac
)
end
-- 內部共用:根據連續或離散色階產生 SVG <stop> 列表
local function buildGradientStops(mapName)
local spec = p.parseColormapSpec(mapName)
local out = {}
if spec and spec.isDiscrete then
local n = max(2, spec.nSteps or (spec.stops and #spec.stops) or 5)
for k = 1, n do
local p0 = floor(((k - 1) / n) * 100 + 0.5)
local p1 = floor((k / n) * 100 + 0.5)
local col = p.sampleColormap((k - 0.5) / n, mapName)
out[#out + 1] = format('<stop offset="%d%%" stop-color="%s"/>', p0, col)
out[#out + 1] = format('<stop offset="%d%%" stop-color="%s"/>', p1, col)
end
else
local nStops = (spec and spec.stops and #spec.stops > 10) and #spec.stops or 10
for s = 0, nStops do
local t = s / nStops
out[#out + 1] = format('<stop offset="%d%%" stop-color="%s"/>',
floor(t * 100 + 0.5), p.sampleColormap(t, mapName))
end
end
return table.concat(out)
end
-- 建構科學色階之 SVG <linearGradient> 節點 (支援 userSpaceOnUse 絕對座標精準對齊,供折線/面積圖使用)
function p.buildColormapGradientDef(gradId, mapName, x1, y1, x2, y2, useUserSpace)
local units = useUserSpace and ' gradientUnits="userSpaceOnUse"' or ''
local x1Str = type(x1) == "number" and format("%.2f", x1) or (x1 or "0%")
local y1Str = type(y1) == "number" and format("%.2f", y1) or (y1 or "0%")
local x2Str = type(x2) == "number" and format("%.2f", x2) or (x2 or "100%")
local y2Str = type(y2) == "number" and format("%.2f", y2) or (y2 or "0%")
return format('<linearGradient id="%s" x1="%s" y1="%s" x2="%s" y2="%s"%s>%s</linearGradient>',
gradId, x1Str, y1Str, x2Str, y2Str, units, buildGradientStops(mapName))
end
-- 建構科學色階之 SVG <radialGradient> 節點 (支援 userSpaceOnUse 絕對座標精準對齊,供雷達圖使用)
function p.buildColormapRadialDef(gradId, mapName, cx, cy, r)
return format('<radialGradient id="%s" cx="%.2f" cy="%.2f" r="%.2f" gradientUnits="userSpaceOnUse">%s</radialGradient>',
gradId, cx, cy, r, buildGradientStops(mapName))
end
-- 建構折線/面積 SVG Path (支援 linear, step, step-before, step-after, monotone, basis, cardinal)
function p.buildPathD(pts, interpolate, isReverse)
local n = #pts
if n == 0 then return "" end
local list = {}
if isReverse then
for i = n, 1, -1 do list[#list + 1] = pts[i] end
else
for i = 1, n do list[i] = pts[i] end
end
local mode = interpolate and string.lower(tostring(interpolate)) or "linear"
local startCmd = isReverse and "L" or "M"
local d = { format("%s %.2f,%.2f", startCmd, list[1][1], list[1][2]) }
if n == 1 then return d[1] end
if mode == "step" then
for i = 1, n - 1 do
local midX = (list[i][1] + list[i + 1][1]) / 2
d[#d + 1] = format("H %.2f V %.2f H %.2f", midX, list[i + 1][2], list[i + 1][1])
end
elseif mode == "step-after" then
for i = 1, n - 1 do
d[#d + 1] = format("H %.2f V %.2f", list[i + 1][1], list[i + 1][2])
end
elseif mode == "step-before" then
for i = 1, n - 1 do
d[#d + 1] = format("V %.2f H %.2f", list[i + 1][2], list[i + 1][1])
end
elseif (mode == "monotone" or mode == "cardinal" or mode == "basis" or mode == "bundle") and n >= 3 then
for i = 1, n - 1 do
local p0 = list[max(1, i - 1)]
local p1 = list[i]
local p2 = list[i + 1]
local p3 = list[min(n, i + 2)]
local cp1x = p1[1] + (p2[1] - p0[1]) / 6
local cp1y = p1[2] + (p2[2] - p0[2]) / 6
local cp2x = p2[1] - (p3[1] - p1[1]) / 6
local cp2y = p2[2] - (p3[2] - p1[2]) / 6
if mode == "monotone" then
local minY, maxY = min(p1[2], p2[2]), max(p1[2], p2[2])
cp1y = max(minY, min(maxY, cp1y))
cp2y = max(minY, min(maxY, cp2y))
end
d[#d + 1] = format("C %.2f,%.2f %.2f,%.2f %.2f,%.2f", cp1x, cp1y, cp2x, cp2y, p2[1], p2[2])
end
else
for i = 2, n do
d[#d + 1] = format("L %.2f,%.2f", list[i][1], list[i][2])
end
end
return table.concat(d, " ")
end
-- 解析自訂 <svg>...</svg> 字串,隔離內部 ID 命名空間,並正規化為中心 (0,0)、單位尺寸 1x1 的 <defs> 群組
function p.buildCustomSvgDef(svgStr, defId)
if type(svgStr) ~= "string" then return nil, false end
local s = mw.text.unstripNoWiki(p.restoreStripMarkers(svgStr))
if find(s, "<") or find(s, "&#") then
s = mw.text.decode(s)
end
local openTag, innerHtml = match(s, "<[sS][vV][gG]%s*([^>]*)>(.*)</[sS][vV][gG]>")
if not openTag or not innerHtml then return nil, false end
innerHtml = gsub(innerHtml, "<[sS][cC][rR][iI][pP][tT].-</[sS][cC][rR][iI][pP][tT]>", "")
innerHtml = gsub(innerHtml, '%s+[oO][nN][%w_]+%s*=%s*["\'][^"\']*["\']', "")
innerHtml = gsub(innerHtml, "</?[dD][eE][fF][sS]%s*>", "")
local idPrefix = defId .. "_"
innerHtml = gsub(innerHtml, '([%s<])id%s*=%s*(["\'])([^"\']+)(%2)', '%1id=%2' .. idPrefix .. '%3%4')
innerHtml = gsub(innerHtml, 'url%(%s*#([^%)%s"\']+)%s*%)', 'url(#' .. idPrefix .. '%1)')
innerHtml = gsub(innerHtml, 'href%s*=%s*(["\'])#([^"\']+)(%1)', 'href=%1#' .. idPrefix .. '%2%3')
-- 判斷是否為多色複雜圖示 (含漸層或含有非黑白之彩色色碼)
local hasNonMonoColor = false
for hex in gmatch(innerHtml, '["\']#(%x+)["\']') do
local lh = string.lower(hex)
if lh ~= "000" and lh ~= "000000" and lh ~= "fff" and lh ~= "ffffff" then
hasNonMonoColor = true
break
end
end
local isMultiColor = (find(innerHtml, "[gG]radient") ~= nil)
or (find(innerHtml, "stop%-color") ~= nil)
or hasNonMonoColor
if not isMultiColor then
-- 單色圖示:將內部寫死的黑色填色/描邊轉為 currentColor,以繼承外層 <use> 的數列顏色
innerHtml = gsub(innerHtml, '([fF][iI][lL][lL]%s*=%s*["\'])#0000?0?0?(["\'])', '%1currentColor%2')
innerHtml = gsub(innerHtml, '([fF][iI][lL][lL]%s*=%s*["\'])[bB][lL][aA][cC][kK](["\'])', '%1currentColor%2')
innerHtml = gsub(innerHtml, '([sS][tT][rR][oO][kK][eE]%s*=%s*["\'])#0000?0?0?(["\'])', '%1currentColor%2')
innerHtml = gsub(innerHtml, '([sS][tT][rR][oO][kK][eE]%s*=%s*["\'])[bB][lL][aA][cC][kK](["\'])', '%1currentColor%2')
end
local vx, vy, vw, vh = match(openTag,
'[vV][iI][eE][wW][bB][oO][xX]%s*=%s*["\']%s*([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)%s*["\']')
local aw = match(openTag, '[wW][iI][dD][tT][hH]%s*=%s*["\']%s*([%+%-]?[%d%.]+)')
local ah = match(openTag, '[hH][eE][iI][gG][hH][tT]%s*=%s*["\']%s*([%+%-]?[%d%.]+)')
local minX = tonumber(vx) or 0
local minY = tonumber(vy) or 0
local w = tonumber(vw) or tonumber(aw) or 24
local h = tonumber(vh) or tonumber(ah) or 24
if w <= 0 then w = 24 end
if h <= 0 then h = 24 end
local inheritedAttrs = {}
local allowedAttr = {
["fill"] = true, ["stroke"] = true, ["stroke-width"] = true,
["stroke-linecap"] = true, ["stroke-linejoin"] = true,
["fill-rule"] = true, ["clip-rule"] = true
}
for k, q, v in gmatch(openTag, '([%w%-]+)%s*=%s*(["\'])(.-)%2') do
local lk = string.lower(k)
if allowedAttr[lk] then
local lv = string.lower(mw.text.trim(v))
if not isMultiColor and (lk == "fill" or lk == "stroke") and lv ~= "none" then
inheritedAttrs[#inheritedAttrs + 1] = format(' %s="currentColor"', lk)
else
inheritedAttrs[#inheritedAttrs + 1] = format(' %s="%s"', lk, v)
end
end
end
if isMultiColor and #inheritedAttrs == 0 then
inheritedAttrs[1] = ' fill="#000000" stroke="none"'
end
local maxDim = max(w, h)
local normScale = 1.0 / maxDim
local tx = -(minX + w / 2) * normScale
local ty = -(minY + h / 2) * normScale
local defNode = format('<g id="%s"><g transform="translate(%.4f,%.4f) scale(%.6f)"%s>%s</g></g>',
defId, tx, ty, normScale, table.concat(inheritedAttrs), innerHtml)
return defNode, isMultiColor
end
local FAST_BUILTIN_SHAPES = {
circle = true, square = true, cross = true, diamond = true,
["triangle-up"] = true, ["triangle-down"] = true,
["triangle-right"] = true, ["triangle-left"] = true
}
local wikiFileCache = {}
-- 識別並提取維基共享資源/站內圖片檔名與明確像素尺寸 (具備 O(1) 標誌性特徵預篩與結果快取)
function p.extractWikiFileName(shape)
if type(shape) ~= "string" or FAST_BUILTIN_SHAPES[shape] then
return nil, nil
end
-- 標誌性特徵預篩:若以 < (SVG 標籤)、& (<svg)、或 M/m (SVG Path 指令) 開頭且無冒號,直接略過
local b1 = string.byte(shape, 1)
if b1 == 60 or b1 == 38 then -- '<' or '&'
return nil, nil
end
if (b1 == 77 or b1 == 109) and not find(shape, ":", 1, true) then -- 'M' or 'm'
local b2 = string.byte(shape, 2)
if b2 == 32 or b2 == 45 or b2 == 43 or b2 == 46 or (b2 and b2 >= 48 and b2 <= 57) then
return nil, nil
end
end
-- 檔案名稱必含 '.' (附檔名) 或 ':' (命名空間)
if not find(shape, ".", 1, true) and not find(shape, ":", 1, true) then
return nil, nil
end
local cached = wikiFileCache[shape]
if cached ~= nil then
if cached == false then return nil, nil end
return cached[1], cached[2]
end
local s = mw.text.trim(p.restoreStripMarkers(shape))
local dq = match(s, '^"(.*)"$')
if dq then
s = mw.text.trim(dq)
else
local sq = match(s, "^'(.*)'$")
if sq then s = mw.text.trim(sq) end
end
if s == "" or find(s, "<[sS][vV][gG]") or find(s, "<[sS][vV][gG]") then
wikiFileCache[shape] = false
return nil, nil
end
local explicitPx = tonumber(match(s, "%|%s*x?(%d+)%s*[pP][xX]"))
or tonumber(match(s, "%|%s*%d+x(%d+)%s*[pP][xX]"))
local inner = match(s, "^%[%[%s*([^%|%]]+)")
if inner then
s = mw.text.trim(inner)
else
s = mw.text.trim(match(s, "^([^%|]+)") or s)
end
local stripped = gsub(s, "^[fF][iI][lL][eE]%s*:%s*", "")
if stripped == s then stripped = gsub(s, "^[iI][mM][aA][gG][eE]%s*:%s*", "") end
if stripped == s then stripped = gsub(s, "^檔案%s*:%s*", "") end
if stripped == s then stripped = gsub(s, "^文件%s*:%s*", "") end
if stripped == s then stripped = gsub(s, "^圖片%s*:%s*", "") end
if stripped == s then stripped = gsub(s, "^图像%s*:%s*", "") end
if stripped ~= s then
local res = mw.text.trim(stripped)
wikiFileCache[shape] = { res, explicitPx }
return res, explicitPx
end
if match(s, "^[^<>%[%]%|{}]+%.[pP][nN][gG]$")
or match(s, "^[^<>%[%]%|{}]+%.[jJ][pP][eE]?[gG]$")
or match(s, "^[^<>%[%]%|{}]+%.[gG][iI][fF]$")
or match(s, "^[^<>%[%]%|{}]+%.[wW][eE][bB][pP]$")
or match(s, "^[^<>%[%]%|{}]+%.[sS][vV][gG]$") then
wikiFileCache[shape] = { s, explicitPx }
return s, explicitPx
end
wikiFileCache[shape] = false
return nil, nil
end
-- 將 customState 收集之維基圖片轉為百分比定位的 HTML 疊層字串
function p.FlushImgOverlays(customState, totalW, totalH)
if not customState or not customState.imgOverlays or #customState.imgOverlays == 0 then
return ""
end
local tw, th = max(1, totalW), max(1, totalH)
local out = {}
for i = 1, #customState.imgOverlays do
local it = customState.imgOverlays[i]
local leftPct = (it.cx / tw) * 100
local topPct = (it.cy / th) * 100
local opStyle = (it.alpha and it.alpha < 1) and format(";opacity:%.2f", it.alpha) or ""
out[#out + 1] = format(
'<span class="gr-sym-img" style="left:%.2f%%;top:%.2f%%%s">[[File:%s|%dpx|link=]]</span>',
leftPct, topPct, opStyle, it.file, it.pxSize
)
end
return table.concat(out)
end
function p.renderSymbol(shape, cx, cy, r, fill, stroke, strokeW, alpha, customState)
local opAttr = (alpha and alpha < 1) and format(' fill-opacity="%.2f" stroke-opacity="%.2f"', alpha, alpha) or ""
local sAttr = (strokeW and strokeW > 0) and format(' stroke="%s" stroke-width="%.1f"', stroke, strokeW) or ""
local fAttr = format(' fill="%s"', fill or "none")
if not FAST_BUILTIN_SHAPES[shape] then
local wikiFile, explicitPx = p.extractWikiFileName(shape)
if wikiFile then
if customState and customState.allowImgOverlay then
customState.imgCount = (customState.imgCount or 0) + 1
if customState.imgCount <= 100 then
customState.imgOverlays = customState.imgOverlays or {}
local calcPx = floor((tonumber(r) or 4.5) * 2.6 + 0.5)
local pxSize = explicitPx and max(explicitPx, calcPx) or calcPx
if customState.isLegend then
pxSize = min(24, calcPx)
end
pxSize = max(6, min(180, pxSize))
customState.imgOverlays[#customState.imgOverlays + 1] = {
file = wikiFile,
cx = cx,
cy = cy,
pxSize = pxSize,
alpha = alpha
}
return ""
end
end
shape = "circle"
if fill == "none" then
fAttr = format(' fill="%s"', stroke or "#1f77b4")
end
elseif type(shape) == "string" and (find(shape, "<[sS][vV][gG]") or find(shape, "<[sS][vV][gG]")) then
if customState then
local defId = customState.map[shape]
if not defId then
customState.count = (customState.count or 0) + 1
defId = "gr-csym-" .. customState.count
local defNode = p.buildCustomSvgDef(shape, defId)
if defNode then
customState.defs[#customState.defs + 1] = defNode
customState.map[shape] = defId
else
customState.map[shape] = false
defId = false
end
end
if defId then
local d = r * 2.2
local seriesCol = (fill and fill ~= "none") and fill or (stroke or "#1f77b4")
local cAttr = format(' color="%s"', seriesCol)
return format('<use href="#%s" xlink:href="#%s" transform="translate(%.2f,%.2f) scale(%.2f)"%s%s%s%s/>',
defId, defId, cx, cy, d, cAttr, fAttr, sAttr, opAttr)
end
end
shape = "circle"
end
end
if shape == "square" then
local half = (customState and customState.isScatterGrid) and (r * 1.02) or (r * 0.9)
return format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f"%s%s%s/>', cx - half, cy - half, half * 2, half * 2, fAttr, sAttr, opAttr)
elseif shape == "diamond" then
local d = r * 1.3
return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx, cy - d, cx + d, cy, cx, cy + d, cx - d, cy, fAttr, sAttr, opAttr)
elseif shape == "triangle-up" then
local d = r * 1.25
return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx, cy - d, cx + d, cy + d * 0.75, cx - d, cy + d * 0.75, fAttr, sAttr, opAttr)
elseif shape == "triangle-down" then
local d = r * 1.25
return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx, cy + d, cx + d, cy - d * 0.75, cx - d, cy - d * 0.75, fAttr, sAttr, opAttr)
elseif shape == "triangle-right" then
local d = r * 1.25
return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx + d, cy, cx - d * 0.75, cy - d, cx - d * 0.75, cy + d, fAttr, sAttr, opAttr)
elseif shape == "triangle-left" then
local d = r * 1.25
return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx - d, cy, cx + d * 0.75, cy - d, cx + d * 0.75, cy + d, fAttr, sAttr, opAttr)
elseif shape == "cross" then
local d = r * 0.95
local cStroke = (strokeW and strokeW > 0) and stroke or (fill ~= "none" and fill or "#1f77b4")
local cw = (strokeW and strokeW > 0) and strokeW or 2.0
return format('<path d="M %.2f,%.2f H %.2f M %.2f,%.2f V %.2f" stroke="%s" stroke-width="%.1f" stroke-linecap="round"%s/>',
cx - d, cy, cx + d, cx, cy - d, cy + d, cStroke, cw, opAttr)
elseif type(shape) == "string" and shape ~= "circle" then
local scale = r * 2.2
return format('<path d="%s" transform="translate(%.2f,%.2f) scale(%.2f)"%s%s%s/>', shape, cx, cy, scale, fAttr, sAttr, opAttr)
else
return format('<circle cx="%.2f" cy="%.2f" r="%.2f"%s%s%s/>', cx, cy, r, fAttr, sAttr, opAttr)
end
end
-- 快取 Tooltip 圖示之 mw.svg Strip Marker (固定 18px 清晰尺寸,確保 Tooltip 外框穩定不破版)
local swatchMarkerCache = {}
function p.buildSwatchHtml(colorHex, swatchSpec)
if type(swatchSpec) == "table" and swatchSpec.shape then
local shp = tostring(swatchSpec.shape)
local fillCol = swatchSpec.fill or colorHex or "#1f77b4"
local sw = tonumber(swatchSpec.strokeWidth) or 0
-- 快速通道:只要是實心圓點 (circle),一律走純 CSS .gr-swatch,零 mw.svg 開銷
if shp == "circle" and fillCol ~= "none" and sw <= 0 then
return colorHex and format('<span class="gr-swatch" style="background:%s"></span>', p.esc(colorHex)) or ""
end
local wikiFile = p.extractWikiFileName(shp)
if wikiFile then
return format('<span class="gr-swatch-file">[[File:%s|18px|link=]]</span>', wikiFile)
end
local strokeCol = swatchSpec.stroke or colorHex or "#1f77b4"
if fillCol == "none" and sw <= 0 then sw = 1.6 end
local cacheKey = table.concat({ shp, tostring(fillCol), tostring(strokeCol), tostring(sw) }, "|")
if swatchMarkerCache[cacheKey] then
return swatchMarkerCache[cacheKey]
end
local cs = { defs = {}, map = {}, count = 0 }
local symNode = p.renderSymbol(shp, 10, 10, 8.5, fillCol, strokeCol, sw, 1, cs)
local innerContent = (cs.count > 0)
and format('<defs>%s</defs>%s', table.concat(cs.defs), symNode)
or symNode
local svgObj = mw.svg.new()
:setAttribute('width', '18')
:setAttribute('height', '18')
:setAttribute('viewBox', '0 0 20 20')
:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
if cs.count > 0 then
svgObj:setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
end
local marker = svgObj
:setImgAttribute('width', '18')
:setImgAttribute('height', '18')
:setImgAttribute('class', 'gr-swatch-img')
:setContent(innerContent)
:toImage()
swatchMarkerCache[cacheKey] = marker
return marker
end
return colorHex and format('<span class="gr-swatch" style="background:%s"></span>', p.esc(colorHex)) or ""
end
-- 根據感應區在畫布上的百分比座標,自動附加防切邊方向類別 (.gr-tip-b, .gr-tip-l, .gr-tip-r)
local function buildTipNode(leftPct, topPct, tipHtml, colorHex, swatchSpec)
local cls = { "gr-tip" }
if topPct < 32 then cls[#cls + 1] = "gr-tip-b" end
if leftPct < 22 then
cls[#cls + 1] = "gr-tip-l"
elseif leftPct > 78 then
cls[#cls + 1] = "gr-tip-r"
end
local swatch = p.buildSwatchHtml(colorHex, swatchSpec)
return format('<span class="%s">%s%s</span>', table.concat(cls, " "), swatch, p.restoreStripMarkers(tipHtml))
end
-- 建立點狀懸停感應區 (折線圖點、散佈氣泡、圓餅扇形、雷達頂點、地圖標記)
function p.buildPointHit(cx, cy, totalW, totalH, tipHtml, colorHex, diameterPx, swatchSpec)
if not tipHtml or tipHtml == "" then return "" end
local leftPct = (cx / max(1, totalW)) * 100
local topPct = (cy / max(1, totalH)) * 100
local sizeStyle = (diameterPx and diameterPx > 14) and format(";width:%dpx;height:%dpx", ceil(diameterPx), ceil(diameterPx)) or ""
return format('<span class="gr-hit" tabindex="0" style="left:%.2f%%;top:%.2f%%%s">%s</span>',
leftPct, topPct, sizeStyle, buildTipNode(leftPct, topPct, tipHtml, colorHex, swatchSpec))
end
-- 建立矩形懸停感應區 (長條圖、堆疊長條、箱形圖、甘特圖、二維熱圖、氣候區間)
function p.buildRectHit(x, y, w, h, totalW, totalH, tipHtml, colorHex, swatchSpec)
if not tipHtml or tipHtml == "" then return "" end
local tw, th = max(1, totalW), max(1, totalH)
local leftPct = (x / tw) * 100
local topPct = (y / th) * 100
local wPct = (max(2, w) / tw) * 100
local hPct = (max(2, h) / th) * 100
local centerLeftPct = leftPct + wPct / 2
return format('<span class="gr-hbar" tabindex="0" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%">%s</span>',
leftPct, topPct, wPct, hPct, buildTipNode(centerLeftPct, topPct, tipHtml, colorHex, swatchSpec))
end
-- 載入 Module:Graph/style.css 模板樣式標籤
local cachedStyleTag = nil
function p.getTemplateStyleTag()
if cachedStyleTag then return cachedStyleTag end
local wf = mw.getCurrentFrame()
if wf and wf.extensionTag then
local ok, tag = pcall(function()
return wf:extensionTag{ name = 'templatestyles', args = { src = 'Module:Graph/style.css' } }
end)
if ok and tag then
cachedStyleTag = tag
return tag
end
end
return ""
end
-- 將 SVG 緩衝區中的 <text> 標籤抽離為瘦身版 HTML 疊層 (結合 Module:Graph/style.css 類別)
function p.finalizeSvgWithOverlay(buf, totalW, totalH, imgClass, hasXlink, extraOverlayHtml, animClass)
local wInt = max(1, ceil(totalW))
local hInt = max(1, ceil(totalH))
local svgOnlyBuf = {}
local overlayBuf = {}
for i = 1, #buf do
local item = buf[i]
if type(item) == "string" and sub(item, 1, 6) == "<text " and not find(item, 'data%-in%-svg="1"', 1, false) then
local attrs, innerText = match(item, "^<text%s+([^>]+)>(.*)</text>$")
if attrs and innerText and innerText ~= "" then
local x = tonumber(match(attrs, 'x="([^"]+)"')) or 0
local y = tonumber(match(attrs, 'y="([^"]+)"')) or 0
local anchor = match(attrs, 'text%-anchor="([^"]+)"') or "start"
local fSize = match(attrs, 'font%-size="([^"]+)"')
local fWeight = match(attrs, 'font%-weight="([^"]+)"')
local fill = match(attrs, 'fill="([^"]+)"')
local opacity = match(attrs, 'opacity="([^"]+)"')
local rotDeg = tonumber(match(attrs, 'rotate%(%s*([%+%-]?[%d%.]+)'))
local hasHalo = match(attrs, 'data%-halo="1"') ~= nil
local cls = { "gr-t" }
local anchorCls = "gr-ts"
local tx = "0%"
if anchor == "middle" then
anchorCls = "gr-tm"
tx = "-50%"
elseif anchor == "end" then
anchorCls = "gr-te"
tx = "-100%"
end
if not rotDeg or abs(rotDeg) <= 1e-4 then
cls[#cls + 1] = anchorCls
end
if fWeight == "bold" then
cls[#cls + 1] = "gr-b"
end
if hasHalo then
cls[#cls + 1] = "gr-halo"
end
local styles = { format("left:%.2f%%;top:%.2f%%", (x / wInt) * 100, (y / hInt) * 100) }
if rotDeg and abs(rotDeg) > 1e-4 then
styles[#styles + 1] = format("transform:rotate(%.1fdeg) translate(%s,-80%%)", rotDeg, tx)
end
if fSize and fSize ~= "11px" then
styles[#styles + 1] = "font-size:" .. fSize
end
if fill and fill ~= "#54595d" then
styles[#styles + 1] = "color:" .. fill
end
if opacity then
styles[#styles + 1] = "opacity:" .. opacity
end
overlayBuf[#overlayBuf + 1] = format('<span class="%s" style="%s">%s</span>',
table.concat(cls, " "), table.concat(styles, ";"), p.restoreStripMarkers(innerText))
end
elseif type(item) == "string" and item ~= "" then
local cleanSvgItem = gsub(item, '%s*data%-in%-svg="1"', "")
svgOnlyBuf[#svgOnlyBuf + 1] = gsub(cleanSvgItem, "__MW_SM_PRE__UNIQ.-QINU__MW_SM_SUF__", "")
end
end
if extraOverlayHtml and extraOverlayHtml ~= "" then
overlayBuf[#overlayBuf + 1] = extraOverlayHtml
end
local svgObj = mw.svg.new()
:setAttribute('viewBox', format('0 0 %d %d', wInt, hInt))
:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
if hasXlink then
svgObj:setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
end
local imgHtml = svgObj
:setImgAttribute('width', tostring(wInt))
:setImgAttribute('height', tostring(hInt))
:setImgAttribute('class', imgClass or 'graph-chart-svg')
:setContent(table.concat(svgOnlyBuf))
:toImage()
local styleTag = p.getTemplateStyleTag()
local rootCls = (animClass and animClass ~= "") and ("svg-graph " .. animClass) or "svg-graph"
if #overlayBuf == 0 then
return styleTag .. format('<div class="%s" style="width:%dpx">%s</div>', rootCls, wInt, imgHtml)
end
return styleTag .. format(
'<div class="%s" style="width:%dpx">%s<div class="gr-ov">%s</div></div>',
rootCls, wInt, imgHtml, table.concat(overlayBuf)
)
end
return p