跳转到内容

模組:沙盒/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, "&lt;[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, "&lt;") 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, "&lt;[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