模組:沙盒/a2569875/PythonScript/ExtraLibrarys/Matplotlib/RendererUtils
外观
-- Module:PythonScript/ExtraLibrarys/Matplotlib/RendererUtils
-- 負責 Matplotlib 2D / 極座標 / 複變函數 / 3D 圖表共用的非矩陣工具函數:
-- 色彩與色譜計算、分數與單位刻度、自訂 SVG <defs> 符號正規化、曲線插值、跳躍點探測與 HTML/CSS 繁簡轉換混合封裝
local p = {}
local format, floor, ceil, abs, max, min, sqrt = string.format, math.floor, math.ceil, math.abs, math.max, math.min, math.sqrt
-- Matplotlib 標準 tab10 預設調色盤
p.DEFAULT_COLORS = {
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"
}
-- Matplotlib 常用單字母與具名顏色對照表
p.NAMED_COLORS = {
b = "#1f77b4", blue = "#1f77b4", ["tab:blue"] = "#1f77b4",
g = "#2ca02c", green = "#2ca02c", ["tab:green"] = "#2ca02c",
r = "#d62728", red = "#d62728", ["tab:red"] = "#d62728",
c = "#17becf", cyan = "#17becf", ["tab:cyan"] = "#17becf",
m = "#e377c2", magenta = "#e377c2", ["tab:pink"] = "#e377c2",
y = "#bcbd22", yellow = "#bcbd22", ["tab:olive"] = "#bcbd22",
k = "#000000", black = "#000000",
w = "#ffffff", white = "#ffffff",
orange = "#ff7f0e", ["tab:orange"] = "#ff7f0e",
purple = "#9467bd", ["tab:purple"] = "#9467bd",
brown = "#8c564b", ["tab:brown"] = "#8c564b",
gray = "#7f7f7f", grey = "#7f7f7f", ["tab:gray"] = "#7f7f7f", ["tab:grey"] = "#7f7f7f",
navy = "#000080", royalblue = "#4169e1", steelblue = "#4682b4", skyblue = "#87ceeb",
teal = "#008080", forestgreen = "#228b22", limegreen = "#32cd32",
gold = "#ffd700", darkorange = "#ff8c00", coral = "#ff7f50", salmon = "#fa8072",
crimson = "#dc143c", firebrick = "#b22222", darkred = "#8b0000",
indigo = "#4b0082", violet = "#ee82ee", deeppink = "#ff1493",
lightgray = "#d3d3d3", lightgrey = "#d3d3d3", silver = "#c0c0c0", dimgray = "#696969"
}
-- 內建科學視覺化色譜控制點 (RGB: 0~255)
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 }
},
jet = {
{ 0, 0, 131 }, { 0, 60, 170 }, { 5, 255, 255 }, { 255, 255, 0 }, { 250, 0, 0 }, { 128, 0, 0 }
},
gray = {
{ 0, 0, 0 }, { 255, 255, 255 }
},
greys = {
{ 255, 255, 255 }, { 0, 0, 0 }
}
}
-- 內建符號形狀與 Matplotlib Marker 對照表 (零外部依賴)
p.SHAPES = {
-- Matplotlib marker 簡寫
o = "circle",
["."] = "circle",
s = "square",
d = "diamond",
D = "diamond",
["^"] = "triangle-up",
v = "triangle-down",
[">"] = "triangle-right",
["<"] = "triangle-left",
["+"] = "cross",
P = "cross",
x = "M-.5,-.5L.5,.5M.5,-.5L-.5,.5",
X = "M-.5,-.5L.5,.5M.5,-.5L-.5,.5",
["*"] = "star",
p = "pentagon",
-- 具名幾何形狀
circle = "circle",
square = "square",
cross = "cross",
diamond = "diamond",
star = "star",
pentagon = "pentagon",
triangle_up = "triangle-up",
["triangle-up"] = "triangle-up",
triangle_down = "triangle-down",
["triangle-down"] = "triangle-down",
triangle_right = "triangle-right",
["triangle-right"] = "triangle-right",
triangle_left = "triangle-left",
["triangle-left"] = "triangle-left",
banana = "m -0.5281,0.2880 0.0020,0.0192 m 0,0 c 0.1253,0.0543 0.2118,0.0679 0.3268,0.0252 0.1569,-0.0582 0.3663,-0.1636 0.4607,-0.3407 0.0824,-0.1547 0.1202,-0.2850 0.0838,-0.4794 l 0.0111,-0.1498 -0.0457,-0.0015 c -0.0024,0.3045 -0.1205,0.5674 -0.3357,0.7414 -0.1409,0.1139 -0.3227,0.1693 -0.5031,0.1856 m 0,0 c 0.1804,-0.0163 0.3622,-0.0717 0.5031,-0.1856 0.2152,-0.1739 0.3329,-0.4291 0.3357,-0.7414 l -0.0422,0.0079 c 0,0 -0.0099,0.1111 -0.0227,0.1644 -0.0537,0.1937 -0.1918,0.3355 -0.3349,0.4481 -0.1393,0.1089 -0.2717,0.2072 -0.4326,0.2806 l -0.0062,0.0260"
}
-- ==========================================
-- 1. 色彩、透明度與色譜工具
-- ==========================================
function p.parseColor(color_in, fallback)
if color_in == nil then return fallback or "#1f77b4", nil end
-- 支援 Python (r, g, b) 或 (r, g, b, a) tuple/list
if type(color_in) == "table" and color_in.elements then
local el = color_in.elements
local r = tonumber(el[1]) or 0
local g = tonumber(el[2]) or 0
local b = tonumber(el[3]) or 0
if r <= 1 and g <= 1 and b <= 1 and (r > 0 or g > 0 or b > 0) then
r, g, b = floor(r * 255 + 0.5), floor(g * 255 + 0.5), floor(b * 255 + 0.5)
end
local hex = format("#%02x%02x%02x", max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
local a = tonumber(el[4])
return hex, a
end
local color_str = mw.text.trim(tostring(color_in))
if color_str == "" or color_str == "none" or color_str == "None" then
return "none", nil
end
local lower = string.lower(color_str)
if p.NAMED_COLORS[color_str] then return p.NAMED_COLORS[color_str], nil end
if p.NAMED_COLORS[lower] then return p.NAMED_COLORS[lower], nil end
-- 支援 C0 ~ C9 週期色碼
local cIdx = string.match(color_str, "^[Cc](%d)$")
if cIdx then
return p.DEFAULT_COLORS[(tonumber(cIdx) % #p.DEFAULT_COLORS) + 1], nil
end
if string.match(color_str, "^#%x%x%x%x%x%x%x%x$") then
local aa = tonumber(string.sub(color_str, 2, 3), 16) or 255
local rgb = "#" .. string.sub(color_str, 4, 9)
return rgb, tonumber(format("%.2f", aa / 255))
end
return color_str, nil
end
function p.hexToRgb(hex)
hex = p.parseColor(hex, "#1f77b4")
if string.match(hex, "^#%x%x%x$") then
local r = tonumber(string.sub(hex, 2, 2), 16) * 17
local g = tonumber(string.sub(hex, 3, 3), 16) * 17
local b = tonumber(string.sub(hex, 4, 4), 16) * 17
return r, g, b
elseif string.match(hex, "^#%x%x%x%x%x%x$") then
local r = tonumber(string.sub(hex, 2, 3), 16)
local g = tonumber(string.sub(hex, 4, 5), 16)
local b = tonumber(string.sub(hex, 6, 7), 16)
return r, g, b
end
return 31, 119, 180
end
-- 根據光照強度 factor (0.15 ~ 1.45) 調整顏色明暗,供 3D 曲面 Flat Shading 使用
function p.shadeColor(color_str, factor)
local rgbHex = p.parseColor(color_str)
local r, g, b = p.hexToRgb(rgbHex)
factor = max(0.15, min(1.45, tonumber(factor) or 1.0))
if factor <= 1.0 then
r = floor(r * factor + 0.5)
g = floor(g * factor + 0.5)
b = floor(b * factor + 0.5)
else
local boost = factor - 1.0
r = floor(r + (255 - r) * boost + 0.5)
g = floor(g + (255 - g) * boost + 0.5)
b = floor(b + (255 - b) * boost + 0.5)
end
return format("#%02x%02x%02x", max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
end
-- HSB 轉 #rrggbb (供複變函數定義域著色圖使用)
function p.hsbToHex(h, s, b)
local k5 = (5 + h / 60) % 6
local k3 = (3 + h / 60) % 6
local k1 = (1 + h / 60) % 6
local f5 = b * (1 - s * max(0, min(k5, 4 - k5, 1)))
local f3 = b * (1 - s * max(0, min(k3, 4 - k3, 1)))
local f1 = b * (1 - s * max(0, min(k1, 4 - k1, 1)))
return format("#%02x%02x%02x", floor(255 * f5 + 0.5), floor(255 * f3 + 0.5), floor(255 * f1 + 0.5))
end
-- 根據正規化數值 t ∈ [0, 1] 與光照強度 shadeFactor 取樣色譜顏色
function p.sampleColormap(cmapName, t, shadeFactor)
t = max(0, min(1, tonumber(t) or 0.5))
local cname = string.lower(tostring(cmapName or "viridis"))
local r, g, b
if cname == "jet" then
-- 採用 CPython Matplotlib _cm._jet_data 原生分段線性公式
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
r, g, b = rf * 255, gf * 255, bf * 255
else
local stops = p.COLORMAPS[cname] 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]
r = c0[1] + (c1[1] - c0[1]) * frac
g = c0[2] + (c1[2] - c0[2]) * frac
b = c0[3] + (c1[3] - c0[3]) * frac
end
local factor = max(0.20, min(1.40, tonumber(shadeFactor) or 1.0))
if factor <= 1.0 then
r, g, b = r * factor, g * factor, b * factor
else
local boost = factor - 1.0
r = r + (255 - r) * boost
g = g + (255 - g) * boost
b = b + (255 - b) * boost
end
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
-- 解析 Matplotlib 格式字串 (如 "r--o", "b^:", "g-.")
function p.parseFmtString(fmt_str)
if type(fmt_str) ~= "string" or fmt_str == "" then return nil, nil, nil end
local s = mw.text.trim(fmt_str)
local color, linestyle, marker = nil, nil, nil
-- 1. 提取線型 (--, -., :, -)
if string.find(s, "%-%-", 1) then
linestyle = "--"; s = string.gsub(s, "%-%-", "", 1)
elseif string.find(s, "%-%.", 1) then
linestyle = "-."; s = string.gsub(s, "%-%.", "", 1)
elseif string.find(s, ":", 1, true) then
linestyle = ":"; s = string.gsub(s, ":", "", 1)
elseif string.find(s, "%-", 1) then
linestyle = "-"; s = string.gsub(s, "%-", "", 1)
end
-- 2. 提取單字母顏色或 C0~C9
local c_idx = string.match(s, "[Cc]%d")
if c_idx then
color = p.parseColor(c_idx)
s = string.gsub(s, "[Cc]%d", "", 1)
else
for i = 1, #s do
local ch = string.sub(s, i, i)
if p.NAMED_COLORS[ch] then
color = p.NAMED_COLORS[ch]
s = string.sub(s, 1, i - 1) .. string.sub(s, i + 1)
break
end
end
end
-- 3. 剩餘字元視為 marker
s = mw.text.trim(s)
if s ~= "" and p.SHAPES[s] then
marker = s
if not linestyle then linestyle = "none" end
end
return color, linestyle, marker
end
function p.getDashArray(ls)
if not ls or ls == "-" or ls == "solid" then return nil end
if ls == "--" or ls == "dashed" then return "6,4" end
if ls == ":" or ls == "dotted" then return "2,3" end
if ls == "-." or ls == "dashdot" then return "6,3,2,3" end
return nil
end
-- ==========================================
-- 2. 字串跳脫、繁簡轉換偵測與混合容器封裝
-- ==========================================
function p.esc(s)
return mw.text.encode(mw.text.unstrip(tostring(s or "")))
end
function p.safeFormat(fmt, val)
local ok, res = pcall(format, fmt, val)
if ok and res then return res end
return format("%.2f", val)
end
-- 將字串中的 $...$ 與 $$...$$ 轉譯為 MediaWiki 原生 <math display="inline"> / <math display="block"> 擴充標籤
function p.expandMathTags(s)
if type(s) ~= "string" or not string.find(s, "%$") then return s end
local frame = mw.getCurrentFrame()
if not (frame and frame.extensionTag) then return s end
-- 先保護跳脫的 \$
local masked = string.gsub(s, "\\%$", "\001ESC_DOLLAR\001")
-- 處理展示型 $$...$$
masked = mw.ustring.gsub(masked, "%$%$([^%$]+)%$%$", function(mathexpr)
local ok, res = pcall(function()
return frame:extensionTag{ name = "math", content = mathexpr, args = { display = "block" } }
end)
return (ok and res) and res or ("$$" .. mathexpr .. "$$")
end)
-- 處理行內型 $...$ (使用 [^%$]+ 確保至少 1 個非 $ 字元且不跨邊界)
masked = mw.ustring.gsub(masked, "%$([^%$]+)%$", function(mathexpr)
local ok, res = pcall(function()
return frame:extensionTag{ name = "math", content = mathexpr, args = { display = "inline" } }
end)
return (ok and res) and res or ("$" .. mathexpr .. "$")
end)
masked = string.gsub(masked, "\001ESC_DOLLAR\001", "$")
return masked
end
-- 偵測字串是否含有中文字元、$...$ 數學式、-{...}- 繁簡轉換語法或 Strip Marker
function p.hasLocalizableText(s)
if not s or s == "" then return false end
local str = tostring(s)
if string.find(str, "-{", 1, true) or string.find(str, "\127", 1, true) or string.find(str, "%$[^%$]+%$") then
return true
end
for code in mw.ustring.gcodepoint(str) do
if code >= 0x2E80 and not (code >= 0x2190 and code <= 0x22FF) then
return true
end
end
return false
end
-- 剝離 -{zh-cn:A;zh-tw:B}- 與 LaTeX 指令外殼,供精準估算圖例框寬度
function p.extractVisibleText(s)
local raw = tostring(s or "")
-- 先將 LaTeX 控制序列 (如 \mathrm{real}, \phi) 簡化為可見字元長度再 unstrip,避免 <math> Strip Marker 干擾寬度計算
raw = string.gsub(raw, "\\mathrm%{(.-)%}", "%1")
raw = string.gsub(raw, "\\text%{(.-)%}", "%1")
raw = string.gsub(raw, "\\[a-zA-Z]+", "M")
raw = string.gsub(raw, "[%$%{%}_%^]", "")
local str = mw.text.unstrip(raw)
str = string.gsub(str, "%-%{(.-)%}%-", function(inner)
local maxPart = ""
for _, part in ipairs(mw.text.split(inner, ";")) do
local clean = mw.ustring.gsub(part, "^%s*[%w%-]+%s*:%s*", "")
if mw.ustring.len(clean) > mw.ustring.len(maxPart) then
maxPart = clean
end
end
return maxPart ~= "" and maxPart or inner
end)
return str
end
-- 估算 HTML 疊加文字寬度 (加上 15% 地區詞長度緩衝)
function p.estimateHtmlTextWidth(s, fontSize)
local vis = p.extractVisibleText(s)
local w = 0
for code in mw.ustring.gcodepoint(vis) do
if code >= 0x2E80 then
w = w + 1.02
elseif code >= 65 and code <= 90 then
w = w + 0.68
else
w = w + 0.52
end
end
return (w * (fontSize or 11.5)) * 1.15 + 6
end
-- 數學公式轉 SVG 排版器 (優先調用站內 Module:FunctionGraph/TextRenderer,若無則使用內建 LaTeX 轉譯)
local _extTextRenderer = nil
local _extLoaded = false
function p.renderMathSVG(exprStr, opts)
opts = opts or {}
local fontSize = tonumber(opts.fontSize) or 11.5
local clean = mw.text.trim(tostring(exprStr or ""))
local strippedMath = string.match(clean, "^%$(.+)%$$")
if strippedMath then clean = strippedMath end
if not _extLoaded then
_extLoaded = true
local ok, mod = pcall(require, "Module:FunctionGraph/TextRenderer")
if ok and type(mod) == "table" and mod.renderMathSVG then
_extTextRenderer = mod
end
end
if _extTextRenderer then
local ok, res = pcall(_extTextRenderer.renderMathSVG, clean, { fontSize = fontSize })
if ok and type(res) == "table" and res.svg then return res end
end
-- 內建輕量級 LaTeX 希臘字母與上下標 SVG 渲染 Fallback
local greek = {
["\\alpha"] = "α", ["\\beta"] = "β", ["\\gamma"] = "γ", ["\\Gamma"] = "Γ",
["\\delta"] = "δ", ["\\Delta"] = "Δ", ["\\theta"] = "θ", ["\\Theta"] = "Θ",
["\\lambda"] = "λ", ["\\mu"] = "μ", ["\\pi"] = "π", ["\\Pi"] = "Π",
["\\sigma"] = "σ", ["\\Sigma"] = "Σ", ["\\omega"] = "ω", ["\\Omega"] = "Ω",
["\\infty"] = "∞", ["\\pm"] = "±", ["\\times"] = "×", ["\\cdot"] = "·"
}
local s = clean
for k, v in pairs(greek) do s = string.gsub(s, k, v) end
local w = p.estimateHtmlTextWidth(s, fontSize)
return {
svg = format('<text x="0" y="0" font-family="sans-serif" font-size="%.1fpx" fill="#2b2b2b">%s</text>', fontSize, p.esc(s)),
width = w,
ascent = fontSize * 0.76,
descent = fontSize * 0.24
}
end
-- 產生注入於 SVG <defs> 內部的精簡 CSS 樣式表,大幅縮減 SVG 展開位元組大小,並支援內部 :hover 動畫
function p.buildInternalSvgStyle(tickFontSize, animateMode)
tickFontSize = tickFontSize or "10px"
local css = {
".mpl-bg{fill:#f8f8f8;stroke:#dcdcdc;stroke-width:1}",
".mpl-wbg{fill:#ffffff;stroke:none}",
".mpl-bdr{fill:none;stroke:#333333;stroke-width:1.1}",
".mpl-grd{stroke:#e4e4e4;stroke-width:0.9}",
".mpl-pgrd{fill:none;stroke:#dcdcdc;stroke-width:0.9;stroke-dasharray:3,2}",
".mpl-pref{fill:none;stroke:#b8b8b8;stroke-width:1.15}",
".mpl-ax{stroke:#2b2b2b;stroke-width:1.3}",
format(".mpl-tk{font-family:sans-serif;font-size:%s;fill:#333}", tickFontSize),
format(".mpl-ptk{font-family:'Times New Roman',serif;font-size:%s;fill:#333;stroke:#f8f8f8;stroke-width:3;paint-order:stroke}", tickFontSize),
".mpl-rtk{font-family:sans-serif;font-size:9px;fill:#222;stroke:#f8f8f8;stroke-width:2.5;paint-order:stroke}",
".mpl-val{font-family:sans-serif;font-size:9.5px;font-weight:bold;fill:#222;stroke:#f8f8f8;stroke-width:2.8;paint-order:stroke}",
".mpl-lbox{fill:#ffffff;fill-opacity:0.92;stroke:#cccccc;stroke-width:1}",
".mpl-line{fill:none;stroke-linecap:round;stroke-linejoin:round}"
}
if animateMode == "draw" or animateMode == "hover" then
css[#css + 1] = ".mpl-anim-path{stroke-dasharray:1400;stroke-dashoffset:0;transition:stroke-dashoffset 0s}"
css[#css + 1] = "svg:hover .mpl-anim-path{animation:mpl-draw 2.2s cubic-bezier(.4,0,.2,1) forwards}"
css[#css + 1] = "@keyframes mpl-draw{0%{stroke-dashoffset:1400}100%{stroke-dashoffset:0}}"
elseif animateMode == "auto" then
css[#css + 1] = ".mpl-anim-path{stroke-dasharray:1400;animation:mpl-draw 2.4s cubic-bezier(.4,0,.2,1) forwards}"
css[#css + 1] = "@keyframes mpl-draw{0%{stroke-dashoffset:1400}100%{stroke-dashoffset:0}}"
end
return "<style>" .. table.concat(css) .. "</style>"
end
-- 取得 TemplateStyles 標籤
local function getTemplateStylesTag()
local frame = mw.getCurrentFrame()
if frame and frame.extensionTag then
local ok, tag = pcall(function()
return frame:extensionTag{ name = 'templatestyles', args = { src = 'Module:沙盒/a2569875/PythonScript/ExtraLibrarys/Matplotlib/Style.css' } }
end)
if ok and tag then return tag end
end
return ""
end
-- 將底層 SVG <img> 與上層參與繁簡轉換的 HTML 文字封裝於相對定位容器內
function p.wrapHybridOutput(svgImg, width, height, overlays)
local tsTag = getTemplateStylesTag()
if not overlays or #overlays == 0 then
return tsTag .. svgImg
end
local out = {
tsTag,
format('<div class="mpl-hybrid" style="width:%dpx;height:%dpx;">', floor(width + 0.5), floor(height + 0.5)),
svgImg
}
for i = 1, #overlays do
out[#out + 1] = overlays[i]
end
out[#out + 1] = '</div>'
return table.concat(out)
end
-- ==========================================
-- 3. 自訂 SVG <defs> 與零外部依賴符號庫
-- ==========================================
function p.parseShapeArray(text)
if type(text) == "table" then return text end
if not text or text == "" then return nil end
local raw = mw.text.unstripNoWiki(tostring(text))
if string.find(raw, "<[sS][vV][gG]") then
raw = mw.text.decode(raw)
end
local svgBlocks = {}
local masked = string.gsub(raw, "<[sS][vV][gG][^>]*>.-</[sS][vV][gG]>", function(matchSvg)
svgBlocks[#svgBlocks + 1] = matchSvg
return "__SVG_SHAPE_" .. #svgBlocks .. "__"
end)
local list = mw.text.split(masked, "%s*,%s*")
if not list or #list == 0 then return nil end
for i = 1, #list do
local token = mw.text.trim(list[i])
local idx = tonumber(string.match(token, "^__SVG_SHAPE_(%d+)__$"))
if idx and svgBlocks[idx] then
list[i] = svgBlocks[idx]
else
list[i] = p.SHAPES[token] or p.SHAPES[string.lower(token)] or token
end
end
return list
end
function p.buildCustomSvgDef(svgStr, defId)
if type(svgStr) ~= "string" then return nil, false end
local s = mw.text.unstripNoWiki(svgStr)
if string.find(s, "<") or string.find(s, "&#") then
s = mw.text.decode(s)
end
local openTag, innerHtml = string.match(s, "<[sS][vV][gG]%s*([^>]*)>(.*)</[sS][vV][gG]>")
if not openTag or not innerHtml then return nil, false end
innerHtml = string.gsub(innerHtml, "<[sS][cC][rR][iI][pP][tT].-</[sS][cC][rR][iI][pP][tT]>", "")
innerHtml = string.gsub(innerHtml, '%s+[oO][nN][%w_]+%s*=%s*["\'][^"\']*["\']', "")
innerHtml = string.gsub(innerHtml, "</?[dD][eE][fF][sS]%s*>", "")
local idPrefix = defId .. "_"
innerHtml = string.gsub(innerHtml, '([%s<])id%s*=%s*(["\'])([^"\']+)(%2)', '%1id=%2' .. idPrefix .. '%3%4')
innerHtml = string.gsub(innerHtml, 'url%(%s*#([^%)%s"\']+)%s*%)', 'url(#' .. idPrefix .. '%1)')
innerHtml = string.gsub(innerHtml, 'href%s*=%s*(["\'])#([^"\']+)(%1)', 'href=%1#' .. idPrefix .. '%2%3')
local isMultiColor = (string.find(innerHtml, "[gG]radient") ~= nil)
or (string.find(innerHtml, "stop%-color") ~= nil)
or (string.find(innerHtml, 'fill%s*=%s*["\']#') ~= nil)
local vx, vy, vw, vh = string.match(openTag,
'[vV][iI][eE][wW][bB][oO][xX]%s*=%s*["\']%s*([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)%s*["\']')
local aw = string.match(openTag, '[wW][iI][dD][tT][hH]%s*=%s*["\']%s*([%+%-]?[%d%.]+)')
local ah = string.match(openTag, '[hH][eE][iI][gG][hH][tT]%s*=%s*["\']%s*([%+%-]?[%d%.]+)')
local minX, minY = tonumber(vx) or 0, 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 string.gmatch(openTag, '([%w%-]+)%s*=%s*(["\'])(.-)%2') do
local lk = string.lower(k)
if allowedAttr[lk] then
inheritedAttrs[#inheritedAttrs + 1] = format(' %s="%s"', lk, v)
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
-- 繪製單一資料點符號
function p.renderSymbol(shape, cx, cy, r, fill, stroke, strokeW, alpha, customState)
shape = p.SHAPES[shape] or shape or "circle"
local opAttr = (alpha and alpha < 1) and format(' fill-opacity="%.2f" stroke-opacity="%.2f"', alpha, alpha) or ""
local sAttr = (strokeW and strokeW > 0 and stroke) and format(' stroke="%s" stroke-width="%.2f"', stroke, strokeW) or ""
local fAttr = format(' fill="%s"', fill or "none")
if type(shape) == "string" and (string.find(shape, "<[sS][vV][gG]") or string.find(shape, "<[sS][vV][gG]")) then
if customState then
local entry = customState.map[shape]
if not entry then
customState.count = customState.count + 1
local defId = "mpl-custom-sym-" .. customState.count
local defNode, isMultiColor = p.buildCustomSvgDef(shape, defId)
if defNode then
customState.defs[#customState.defs + 1] = defNode
entry = { ref = "#" .. defId, isMultiColor = isMultiColor }
else
entry = { ref = "circle", isMultiColor = false }
end
customState.map[shape] = entry
end
shape = entry.ref
if entry.isMultiColor then
local scaleD = r * 2.2
return format('<use href="%s" xlink:href="%s" transform="translate(%.2f,%.2f) scale(%.2f)"%s/>',
shape, shape, cx, cy, scaleD, opAttr)
end
else
shape = "circle"
end
end
if shape == "circle" then
return format('<circle cx="%.2f" cy="%.2f" r="%.2f"%s%s%s/>', cx, cy, r, fAttr, sAttr, opAttr)
elseif shape == "square" then
return format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f"%s%s%s/>', cx - r, cy - r, r * 2, r * 2, fAttr, sAttr, opAttr)
elseif shape == "diamond" then
local d = r * 1.25
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.8, cx - d, cy + d * 0.8, 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 - d, cy - d * 0.8, cx + d, cy - d * 0.8, cx, cy + d, 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 * 0.8, cy - d, cx + d, cy, cx - d * 0.8, 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 * 0.8, cy - d, cx - d, cy, cx + d * 0.8, cy + d, fAttr, sAttr, opAttr)
elseif shape == "cross" then
local w = r * 0.42
return format('<path d="M %.2f,%.2f H %.2f V %.2f H %.2f V %.2f H %.2f V %.2f H %.2f V %.2f H %.2f V %.2f H %.2f Z"%s%s%s/>',
cx - w, cy - r, cx + w, cy - w, cx + r, cy - w, cx + r, cy + w, cx + w, cy + w, cx + w, cy + r, cx - w, fAttr, sAttr, opAttr)
elseif shape == "star" then
local pts = {}
for i = 0, 9 do
local ang = -math.pi / 2 + i * math.pi / 5
local rad = (i % 2 == 0) and (r * 1.35) or (r * 0.55)
pts[#pts + 1] = format("%.2f,%.2f", cx + rad * math.cos(ang), cy + rad * math.sin(ang))
end
return format('<polygon points="%s"%s%s%s/>', table.concat(pts, " "), fAttr, sAttr, opAttr)
elseif shape == "pentagon" then
local pts = {}
for i = 0, 4 do
local ang = -math.pi / 2 + i * 2 * math.pi / 5
local rad = r * 1.2
pts[#pts + 1] = format("%.2f,%.2f", cx + rad * math.cos(ang), cy + rad * math.sin(ang))
end
return format('<polygon points="%s"%s%s%s/>', table.concat(pts, " "), fAttr, sAttr, opAttr)
elseif type(shape) == "string" and string.sub(shape, 1, 1) == "#" then
local scaleD = r * 2.2
local curCol = (fill and fill ~= "none") and fill or (stroke or "#000000")
local strokeAttr = (strokeW and strokeW > 0 and stroke) and format(' stroke="%s"', stroke) or ""
return format('<use href="%s" xlink:href="%s" transform="translate(%.2f,%.2f) scale(%.2f)" fill="%s" color="%s"%s%s/>',
shape, shape, cx, cy, scaleD, fill or "none", curCol, strokeAttr, opAttr)
else
local sw = (strokeW and strokeW > 0) and strokeW or 1.5
return format('<path d="%s" transform="translate(%.2f,%.2f) scale(%.2f)" fill="%s" stroke="%s" stroke-width="%.2f"%s/>',
shape, cx, cy, r * 2.2, fill or "none", stroke or fill or "#000", sw / (r * 2.2), opAttr)
end
end
-- ==========================================
-- 4. 連分數展開、自訂單位與座標刻度計算
-- ==========================================
function p.toFraction(val, maxDenom, eps)
maxDenom = maxDenom or 24
eps = eps or 1e-4
if abs(val) < 1e-10 then return 0, 1 end
local sign = (val < 0) and -1 or 1
local x = abs(val)
local h1, h2, k1, k2 = 1, 0, 0, 1
local b = x
for _ = 1, 12 do
local a = floor(b + 1e-11)
local h = a * h1 + h2
local k = a * k1 + k2
if k > maxDenom then break end
h2, h1 = h1, h
k2, k1 = k1, k
if abs(x - h1 / k1) <= eps * max(1, x) then return sign * h1, k1 end
local rem = b - a
if rem < 1e-9 then break end
b = 1 / rem
end
if k1 > 0 and k1 <= maxDenom and abs(x - h1 / k1) <= eps * max(1, x) then return sign * h1, k1 end
return nil, nil
end
function p.formatTickValue(val, unit, unitLabel, useFrac, defaultFmt)
if abs(val) < 1e-11 then return "0" end
unit = (unit and unit > 0) and unit or 1.0
unitLabel = unitLabel or ""
local hasUnit = (unitLabel ~= "") or (abs(unit - 1.0) > 1e-9)
local scaled = val / unit
if useFrac then
local num, den = p.toFraction(scaled, 24, 1e-3)
if num and den then
if num == 0 then return "0" end
local signStr = (num < 0) and "-" or ""
local absNum = abs(num)
if den == 1 then
if hasUnit and unitLabel ~= "" then
if absNum == 1 then return signStr .. unitLabel end
return format("%s%d%s", signStr, absNum, unitLabel)
end
return format("%s%d", signStr, absNum)
else
if hasUnit and unitLabel ~= "" then
if absNum == 1 then return format("%s%s/%d", signStr, unitLabel, den) end
return format("%s%d%s/%d", signStr, absNum, unitLabel, den)
end
return format("%s%d/%d", signStr, absNum, den)
end
end
end
if hasUnit then
if abs(scaled - floor(scaled + 0.5)) < 1e-6 then
local iv = floor(scaled + 0.5)
if iv == 0 then return "0" end
if unitLabel ~= "" then
if iv == 1 then return unitLabel end
if iv == -1 then return "-" .. unitLabel end
return format("%d%s", iv, unitLabel)
end
return tostring(iv)
end
local numStr = string.gsub(format("%.2f", scaled), "%.?0+$", "")
return numStr .. unitLabel
end
if defaultFmt then return p.safeFormat(defaultFmt, val) end
if abs(val - floor(val + 0.5)) < 1e-7 then return tostring(floor(val + 0.5)) end
return string.gsub(format("%.2f", val), "0+$", ""):gsub("%.$", "")
end
function p.buildAxisTicks(minVal, maxVal, customStep, unit, hasUnit, strictTicks, divs)
local span = maxVal - minVal
if span <= 0 then return { minVal }, false end
local step = tonumber(customStep)
if not step or step <= 0 then
if hasUnit and unit and unit > 0 then
local uSpan = span / unit
local rawStep = uSpan / (divs or 6)
local cleanSteps = { 0.125, 0.25, 0.5, 1, 2, 4, 5, 10, 20 }
local chosen = 1
for _, cs in ipairs(cleanSteps) do
if rawStep <= cs * 1.15 then chosen = cs; break end
chosen = cs
end
step = chosen * unit
elseif strictTicks ~= false then
local targetCount = divs or 6
local rawStep = span / targetCount
local mag = 10 ^ floor(math.log10(rawStep))
local res = rawStep / mag
if strictTicks == true then
if res > 7.0 then step = 10 * mag
elseif res > 3.0 then step = 5 * mag
elseif res > 1.4 then step = 2 * mag
else step = mag end
else
if res > 7.0 then step = 10 * mag
elseif res > 3.2 then step = 5 * mag
elseif res > 2.2 then step = 2.5 * mag
elseif res > 1.4 then step = 2 * mag
else step = mag end
end
end
end
if step and step > 0 then
local ticks = {}
local startTick = ceil((minVal - 1e-9) / step) * step
if (maxVal - startTick) / step > 40 then
step = (maxVal - minVal) / 8
startTick = minVal
end
for v = startTick, maxVal + 1e-9, step do
if abs(v) < 1e-10 then v = 0 end
ticks[#ticks + 1] = v
end
if #ticks >= 2 then return ticks, true end
end
divs = divs or 6
local ticks = {}
for i = 0, divs do
local v = minVal + (i / divs) * span
if abs(v) < 1e-10 then v = 0 end
ticks[#ticks + 1] = v
end
return ticks, false
end
function p.getComplexTicks(minVal, maxVal, customStep, maxCount)
local ticks = p.buildAxisTicks(minVal, maxVal, customStep, nil, false, nil, maxCount or 5)
return ticks
end
-- ==========================================
-- 5. 曲線插值與跳躍不連續點探測
-- ==========================================
function p.buildSegmentPath(pts, interpMode)
local n = #pts
if n == 0 then return "" end
if n == 1 then return format("M %.2f,%.2f", pts[1][1], pts[1][2]) end
interpMode = string.lower(tostring(interpMode or "linear"))
if interpMode == "linear" then
local buf = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do buf[#buf + 1] = format("L %.2f,%.2f", pts[i][1], pts[i][2]) end
return table.concat(buf, " ")
elseif interpMode == "step" or interpMode == "step-after" or interpMode == "step-post" or interpMode == "post" then
local buf = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do buf[#buf + 1] = format("H %.2f V %.2f", pts[i][1], pts[i][2]) end
return table.concat(buf, " ")
elseif interpMode == "step-before" or interpMode == "step-pre" or interpMode == "pre" then
local buf = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do buf[#buf + 1] = format("V %.2f H %.2f", pts[i][2], pts[i][1]) end
return table.concat(buf, " ")
elseif interpMode == "step-mid" or interpMode == "mid" then
local buf = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do
local midX = 0.5 * (pts[i - 1][1] + pts[i][1])
buf[#buf + 1] = format("H %.2f V %.2f H %.2f", midX, pts[i][2], pts[i][1])
end
return table.concat(buf, " ")
end
-- Monotone Cubic Hermite 樣條平滑插值
if n == 2 or n > 180 then
local buf = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do buf[#buf + 1] = format("L %.2f,%.2f", pts[i][1], pts[i][2]) end
return table.concat(buf, " ")
end
local is_monotonic_x = true
local dx_arr, dy_arr, m_arr = {}, {}, {}
for i = 1, n - 1 do
local dx = pts[i + 1][1] - pts[i][1]
local dy = pts[i + 1][2] - pts[i][2]
if dx <= 1e-5 then is_monotonic_x = false; break end
dx_arr[i], dy_arr[i], m_arr[i] = dx, dy, dy / dx
end
if not is_monotonic_x then
local buf = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 2, n do buf[#buf + 1] = format("L %.2f,%.2f", pts[i][1], pts[i][2]) end
return table.concat(buf, " ")
end
local tangents = { [1] = m_arr[1], [n] = m_arr[n - 1] }
for i = 2, n - 1 do
local m0, m1 = m_arr[i - 1], m_arr[i]
if m0 * m1 <= 0 then
tangents[i] = 0
else
local dx0, dx1 = dx_arr[i - 1], dx_arr[i]
local common = dx0 + dx1
tangents[i] = 3 * common / ((common + dx1) / m0 + (common + dx0) / m1)
end
end
local buf = { format("M %.2f,%.2f", pts[1][1], pts[1][2]) }
for i = 1, n - 1 do
local x0, y0 = pts[i][1], pts[i][2]
local x1, y1 = pts[i + 1][1], pts[i + 1][2]
local dx3 = dx_arr[i] / 3
buf[#buf + 1] = format(
"C %.2f,%.2f %.2f,%.2f %.2f,%.2f",
x0 + dx3, y0 + tangents[i] * dx3,
x1 - dx3, y1 - tangents[i + 1] * dx3,
x1, y1
)
end
return table.concat(buf, " ")
end
-- 從 1D 離散數據陣列 (xs, ys) 中自動探測跳躍不連續點並推算左右極限與實心/空心點位
function p.detectArrayJumps(xs, ys, ySpan, jumpThreshold, jumpType)
local jumps = {}
local n = min(#(xs or {}), #(ys or {}))
if n < 3 then return jumps end
local th = tonumber(jumpThreshold) or max(0.25, (ySpan or 1) * 0.25)
for i = 1, n - 1 do
local x0, y0 = tonumber(xs[i]), tonumber(ys[i])
local x1, y1 = tonumber(xs[i + 1]), tonumber(ys[i + 1])
if x0 and y0 and x1 and y1 and x0 == x0 and y0 == y0 and x1 == x1 and y1 == y1 then
if abs(y1 - y0) >= th then
-- 利用前後斜率判斷是否為真實跳躍(相鄰段平坦但此處劇烈跳動)
local dyPrev = (i > 1 and tonumber(ys[i - 1])) and abs(y0 - tonumber(ys[i - 1])) or 0
local dyNext = (i < n - 1 and tonumber(ys[i + 2])) and abs(tonumber(ys[i + 2]) - y1) or 0
if abs(y1 - y0) > max(dyPrev, dyNext) * 3.0 or jumpThreshold ~= nil then
local xJ = 0.5 * (x0 + x1)
local nearInt = floor(xJ * 2 + 0.5) / 2
if abs(xJ - nearInt) <= abs(x1 - x0) * 0.6 then
xJ = nearInt
end
local yVal = nil
if jumpType == "left" or jumpType == "left-continuous" then
yVal = y0
else
yVal = y1
end
jumps[#jumps + 1] = { x = xJ, yL = y0, yR = y1, y0 = yVal, splitIdx = i }
end
end
end
end
return jumps
end
return p