跳转到内容

模組:FunctionGraph/RenderUtils

被永久保护的模块
维基百科,自由的百科全书
-- Module:FunctionGraph/RenderUtils
-- 負責 2D / 3D 圖表共用的非矩陣工具函數:色彩與光影計算、分數與單位刻度、自訂 SVG 符號正規化、曲線插值與 HTML/CSS 混合封裝
local p = {}

local format, floor, ceil, abs, max, min = string.format, math.floor, math.ceil, math.abs, math.max, math.min

p.DEFAULT_COLORS = {
	"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
	"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
	"#ff4444", "#4444ff", "#2ca02c", "#ff9900", "#9467bd",
	"#17becf", "#e377c2", "#8c564b", "#7f7f7f", "#bcbd22"
}

-- 內建符號形狀與路徑定義 (零外部依賴)
p.SHAPES = {
	circle = "circle",
	x = "M-.5,-.5L.5,.5M.5,-.5L-.5,.5",
	square = "square",
	cross = "cross",
	diamond = "diamond",
	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"
}

-- 解析顏色字串,支援 #aarrggbb 透明度提取
function p.parseColor(color_str)
	color_str = mw.text.trim(tostring(color_str or "#1f77b4"))
	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

-- 將 #rrggbb 或 #rgb 轉為 (r, g, b) 數值 [0..255]
function p.hexToRgb(hex)
	hex = mw.text.trim(tostring(hex or "#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.2 ~ 1.4) 調整顏色明暗,供 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 (供複變函數與 3D 高度色譜使用)
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

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

-- 偵測字串是否含有中文字元、-{...}- 繁簡轉換語法或 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) 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}- 語法外殼並取最長分支,供精準估算圖例框寬度
function p.extractVisibleText(s)
	local str = mw.text.unstrip(tostring(s or ""))
	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) * 1.15 + 6
end

-- 產生注入於 SVG <defs> 內部的精簡 CSS 樣式表,大幅縮減 SVG 展開位元組大小,並支援內部 :hover 動畫
function p.buildInternalSvgStyle(tickFontSize, animateMode)
	tickFontSize = tickFontSize or "10px"
	local css = {
		".fg-bg{fill:#f8f8f8;stroke:#dcdcdc;stroke-width:1}",
		".fg-bdr{fill:none;stroke:#c8c8c8;stroke-width:1}",
		".fg-grd{stroke:#e6e6e6;stroke-width:1}",
		".fg-pgrd{fill:none;stroke:#dcdcdc;stroke-width:1;stroke-dasharray:3,2}",
		".fg-pref{fill:none;stroke:#b8b8b8;stroke-width:1.15}",
		".fg-ax{stroke:#2b2b2b;stroke-width:1.5}",
		format(".fg-tk{font-family:sans-serif;font-size:%s;fill:#333}", tickFontSize),
		format(".fg-ptk{font-family:'Times New Roman',serif;font-size:%s;fill:#333;stroke:#f8f8f8;stroke-width:3;paint-order:stroke}", tickFontSize),
		".fg-rtk{font-family:sans-serif;font-size:9px;fill:#222;stroke:#f8f8f8;stroke-width:2.5;paint-order:stroke}",
		".fg-val{font-family:sans-serif;font-size:9.5px;font-weight:bold;fill:#222;stroke:#f8f8f8;stroke-width:2.8;paint-order:stroke}",
		".fg-lbox{fill:#fff;fill-opacity:0.92;stroke:#c8c8c8;stroke-width:1}",
		".fg-line{fill:none;stroke-linecap:round;stroke-linejoin:round}"
	}
	if animateMode == "draw" or animateMode == "hover" then
		css[#css + 1] = ".fg-anim-path{stroke-dasharray:1200;stroke-dashoffset:0;transition:stroke-dashoffset 0s}"
		css[#css + 1] = "svg:hover .fg-anim-path{animation:fg-draw 2.2s cubic-bezier(.4,0,.2,1) forwards}"
		css[#css + 1] = "@keyframes fg-draw{0%{stroke-dashoffset:1200}100%{stroke-dashoffset:0}}"
	elseif animateMode == "auto" then
		css[#css + 1] = ".fg-anim-path{stroke-dasharray:1200;animation:fg-draw 2.4s cubic-bezier(.4,0,.2,1) forwards}"
		css[#css + 1] = "@keyframes fg-draw{0%{stroke-dashoffset:1200}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:FunctionGraph/style.css' } }
		end)
		if ok and tag then return tag end
	end
	return ""
end

-- 將底層 SVG <img> 與上層參與繁簡轉換的 HTML 文字封裝於相對定位容器內
function p.wrapHybridOutput(svgImg, width, height, overlays, opts)
	opts = opts or {}
	local tsTag = getTemplateStylesTag()
	if not overlays or #overlays == 0 then
		return tsTag .. svgImg
	end
	local out = {
		tsTag,
		format('<div class="function-graph-hybrid" style="width:%dpx;height:%dpx;">', width, height),
		svgImg
	}
	for i = 1, #overlays do
		out[#out + 1] = overlays[i]
	end
	out[#out + 1] = '</div>'
	return table.concat(out)
end

-- 解析 symbolsShape 參數:支援將完整 <svg>...</svg> 視為單一元素
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[string.lower(token)] or p.SHAPES[token] or token
		end
	end
	return list
end

-- 解析自訂 <svg>...</svg> 字串,正規化為中心 (0,0)、單位尺寸 1x1 的 <defs> 群組
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*([%+%-]?[%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 = "fg-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.45
		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 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

-- 連分數展開:將浮點數轉為最簡分數 (n, d)
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

-- 格式化刻度標籤 (支援 Unit 單位換算、UnitLabel 符號與 tickFraction 分數顯示)
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

-- 產生座標軸刻度陣列 (支援 forceIntStep 強制整數刻度步進)
function p.buildAxisTicks(minVal, maxVal, customStep, unit, hasUnit, strictTicks, divs, forceIntStep)
	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 then
			local targetCount = divs or 6
			local rawStep = span / targetCount
			local mag = 10 ^ floor(math.log10(rawStep))
			local res = rawStep / mag
			if res > 5 then step = 10 * mag
			elseif res > 2 then step = 5 * mag
			elseif res > 1 then step = 2 * mag
			else step = mag end
			if forceIntStep and step < 1 then
				step = 1
			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 8
	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

-- 自動計算包含 0 的漂亮刻度 (Nice Ticks,供複變函數與 3D 軸使用)
function p.getComplexTicks(minVal, maxVal, customStep, maxCount)
	local span = maxVal - minVal
	if span <= 0 then return { minVal } end
	local step = tonumber(customStep)
	if not step or step <= 0 then
		maxCount = maxCount or 5
		local rawStep = span / maxCount
		local mag = 10 ^ floor(math.log10(rawStep))
		local residual = rawStep / mag
		if residual > 5 then step = 10 * mag
		elseif residual > 2.5 then step = 5 * mag
		elseif residual > 2 then step = 2.5 * mag
		elseif residual > 1 then step = 2 * mag
		else step = mag end
	end

	local ticks = {}
	local first = ceil((minVal - 1e-9) / step) * step
	for val = first, maxVal + 1e-9, step do
		if abs(val) < 1e-10 then val = 0 end
		ticks[#ticks + 1] = val
	end
	return ticks
end

-- 單調三次 Hermite 樣條插值與各種插值路徑產生器
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

	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" 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" 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" 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

	if n == 2 or n > 400 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

-- 提取單點座標 (嚴格檢查 false 佔位符)
function p.getPointXY(s, j)
	local vx, vy
	if s.raw_x ~= nil then
		local rx = s.raw_x[j]
		if rx ~= false and rx ~= nil then vx = tonumber(rx) end
	elseif s.x ~= nil then
		vx = tonumber(s.x[j])
	end
	if s.raw_y ~= nil then
		local ry = s.raw_y[j]
		if ry ~= false and ry ~= nil then vy = tonumber(ry) end
	elseif s.y ~= nil then
		vy = tonumber(s.y[j])
	end
	return vx, vy
end

-- 沿 2D 螢幕像素弧長 (Arc Length) 均勻取樣 quota 個點,並重新調用 s.eval_fn(t*) 計算精確函數值
function p.sampleCurveTooltipsByArcLength(s, mapX, mapY, xMin, xMax, yMin, yMax, jumpCutSpan, userJumpTh, quota)
	if not quota or quota <= 0 then return {} end
	local n_pts = max(#(s.x or {}), #(s.raw_x or {}))
	if n_pts == 0 then return {} end

	local raw_t = s.raw_t or {}
	local jumps = s.jumps or {}
	local jumpIdx, nJumps = 1, #jumps

	-- 收集所有位於可見繪圖區內的折線子線段 (px0, py0, px1, py1, t0, t1, vx0, vy0, vx1, vy1, segLen)
	local visSegs = {}
	local totalLen = 0
	local prev_vx, prev_vy, prev_t = nil, nil, nil

	-- 將跨越繪圖區邊界的線段裁切至 [xMin, xMax] x [yMin, yMax] 內
	local function addClippedSubSegment(vx0, vy0, t0, vx1, vy1, t1)
		local u0, u1 = 0.0, 1.0
		local dx, dy = vx1 - vx0, vy1 - vy0
		local p_arr = { -dx, dx, -dy, dy }
		local q_arr = { vx0 - xMin, xMax - vx0, vy0 - yMin, yMax - vy0 }
		for k = 1, 4 do
			local pk, qk = p_arr[k], q_arr[k]
			if abs(pk) < 1e-12 then
				if qk < 0 then return end
			else
				local r = qk / pk
				if pk < 0 then
					if r > u1 then return elseif r > u0 then u0 = r end
				else
					if r < u0 then return elseif r < u1 then u1 = r end
				end
			end
		end
		if u1 < u0 then return end
		local cx0, cy0, ct0 = vx0 + u0 * dx, vy0 + u0 * dy, t0 + u0 * (t1 - t0)
		local cx1, cy1, ct1 = vx0 + u1 * dx, vy0 + u1 * dy, t0 + u1 * (t1 - t0)
		local px0, py0 = mapX(cx0), mapY(cy0)
		local px1, py1 = mapX(cx1), mapY(cy1)
		local segLen = math.sqrt((px1 - px0) ^ 2 + (py1 - py0) ^ 2)
		if segLen > 1e-4 then
			totalLen = totalLen + segLen
			visSegs[#visSegs + 1] = {
				px0 = px0, py0 = py0, px1 = px1, py1 = py1,
				vx0 = cx0, vy0 = cy0, vx1 = cx1, vy1 = cy1,
				t0 = ct0, t1 = ct1,
				len = segLen, cumStart = totalLen - segLen, cumEnd = totalLen
			}
		end
	end

	local yMargin = (yMax - yMin) * 3.5
	for j = 1, n_pts do
		local vx, vy = p.getPointXY(s, j)
		local vt = tonumber(raw_t[j]) or vx or j
		local isValid = vx and vy and (vx == vx) and (vy == vy)
			and abs(vx) < 1e12 and abs(vy) < 1e12
			and vy >= (yMin - yMargin) and vy <= (yMax + yMargin)

		if isValid then
			while jumpIdx <= nJumps and jumps[jumpIdx].x < vx - 1e-7 do
				local jmp = jumps[jumpIdx]
				if prev_vx and jmp.x > prev_vx + 1e-7 then
					addClippedSubSegment(prev_vx, prev_vy, prev_t, jmp.x, jmp.yL, jmp.x)
					prev_vx, prev_vy, prev_t = jmp.x, jmp.yR, jmp.x
				end
				jumpIdx = jumpIdx + 1
			end

			if jumpIdx <= nJumps and abs(vx - jumps[jumpIdx].x) <= 1e-6 then
				local jmp = jumps[jumpIdx]
				if prev_vx then
					addClippedSubSegment(prev_vx, prev_vy, prev_t, jmp.x, jmp.yL, jmp.x)
				end
				prev_vx, prev_vy, prev_t = jmp.x, jmp.yR, jmp.x
				jumpIdx = jumpIdx + 1
			else
				local isAutoCut = (not s.polar and not s.parametric and prev_vy) and (
					(userJumpTh and abs(vy - prev_vy) >= userJumpTh) or
					(abs(vy - prev_vy) > jumpCutSpan and ((vy - yMin) * (prev_vy - yMin) < 0 or (vy > yMax and prev_vy < yMin) or (vy < yMin and prev_vy > yMax)))
				)
				if isAutoCut then
					prev_vx, prev_vy, prev_t = nil, nil, nil
				end
				if prev_vx then
					addClippedSubSegment(prev_vx, prev_vy, prev_t, vx, vy, vt)
				end
				prev_vx, prev_vy, prev_t = vx, vy, vt
			end
		else
			prev_vx, prev_vy, prev_t = nil, nil, nil
		end
	end

	if #visSegs == 0 or totalLen <= 0 then return {} end

	local sampled = {}
	local segIdx = 1
	local eval_fn = s.eval_fn

	for m = 1, quota do
		local targetS = ((m - 0.5) / quota) * totalLen
		while segIdx < #visSegs and visSegs[segIdx].cumEnd < targetS do
			segIdx = segIdx + 1
		end
		local seg = visSegs[segIdx]
		local alpha = (seg.len > 1e-6) and max(0, min(1, (targetS - seg.cumStart) / seg.len)) or 0.5
		local t_star = seg.t0 + alpha * (seg.t1 - seg.t0)
		local vx, vy = nil, nil

		if eval_fn then
			local rx, ry = eval_fn(t_star)
			if rx and ry and rx == rx and ry == ry and rx >= xMin - 1e-5 and rx <= xMax + 1e-5 and ry >= yMin - 1e-5 and ry <= yMax + 1e-5 then
				vx, vy = rx, ry
			end
		end
		if not vx or not vy then
			vx = seg.vx0 + alpha * (seg.vx1 - seg.vx0)
			vy = seg.vy0 + alpha * (seg.vy1 - seg.vy0)
			if s.project_fn then
				local px_ref, py_ref = s.project_fn(vx, vy)
				if px_ref and py_ref and px_ref >= xMin - 1e-5 and px_ref <= xMax + 1e-5 and py_ref >= yMin - 1e-5 and py_ref <= yMax + 1e-5 then
					vx, vy = px_ref, py_ref
				end
			end
		end

		sampled[#sampled + 1] = {
			x = vx,
			y = vy,
			px = mapX(vx),
			py = mapY(vy)
		}
	end

	return sampled
end

return p