跳转到内容

模組:Graph

被永久保护的模块
维基百科,自由的百科全书

-- Module:Graph

-- ATTENTION:	Please edit this code at https://de.wikipedia.org/wiki/Modul:Graph
--          	This way all wiki languages can stay in sync. Thank you!
-- 2026-10-25 A2569875註: Vega圖表功能早壞了,不必去跟隨https://de.wikipedia.org/wiki/Modul:Graph
--
--	BUGS:	X-Axis label format bug? (xAxisFormat =) https://en.wikipedia.org/wiki/Template_talk:Graph:Chart#X-Axis_label_format_bug?_(xAxisFormat_=)
--			linewidths - doesnt work for two values (eg 0, 1) but work if added third value of both are zeros? Same for marksStroke - probably bug in Graph extension
--			clamp - "clamp" used to avoid marks outside marks area, "clip" should be use instead but not working in Graph extension, see https://phabricator.wikimedia.org/T251709
--	TODO:  
--			marks:
--				- line strokeDash + serialization,
--				- symStroke serialization
--				- symbolsNoFill serialization
--				- arbitrary SVG path symbol shape as symbolsShape argument
--				- annotations
--					- vertical / horizontal line at specific values [DONE] 2020-09-01
--					- rectangle shape for x,y data range
--				- graph type serialization (deep rebuild reqired)
--	     - second axis (deep rebuild required - assignment of series to one of two axies) 

-- Version History (_PLEASE UPDATE when modifying anything_):
--   2026-11-04 支援直接解析<graph>內原生json並直接繪製之(即使用本模組取代<graph>)
--   2026-10-30 重新支援繁簡轉換
--   2026-10-25 修改: extractRecordList 支援 statistics 等外層鍵名;相容舊版 content= 參數;
--   2026-10-24 由於<graph>已停用,因此重寫為基於SVG渲染的模式
--   2020-09-01 Vertical and horizontal line annotations
--   2020-08-08 New logic for "nice" for x axis (problem with scale when xType = "date") and grid
--   2020-06-21 Serializes symbol size
--              transparent symbosls (from line colour) - buggy (incorrect opacity on overlap with line)
--              Linewidth serialized with "linewidths"
--              Variable symbol size and shape of symbols on line charts, default showSymbols = 2, default symbolsShape = circle, symbolsStroke = 0
--              p.chartDebuger(frame) for easy debug and JSON output 
--   2020-06-07 Allow lowercase variables for use with [[Template:Wikidata list]]
--   2020-05-27 Map: allow specification which feature to display and changing the map center
--   2020-04-08 Change default showValues.fontcolor from black to persistentGrey
--   2020-04-06 Logarithmic scale outputs wrong axis labels when "nice"=true
--   2020-03-11 Allow user-defined scale types, e.g. logarithmic scale
--   2019-11-08 Apply color-inversion-friendliness to legend title, labels, and xGrid
--   2019-01-24 Allow comma-separated lists to contain values with commas
--   2018-10-13 Fix browser color-inversion issues via #54595d per [[mw:Template:Graph:PageViews]]
--   2018-09-16 Allow disabling the legend for templates
--   2018-09-10 Allow grid lines
--   2018-08-26 Use user-defined order for stacked charts
--   2018-02-11 Force usage of explicitely provided x minimum and/or maximum values, rotation of x labels
--   2017-08-08 Added showSymbols param to show symbols on line charts
--   2016-05-16 Added encodeTitleForPath() to help all path-based APIs graphs like pageviews
--   2016-03-20 Allow omitted data for charts, labels for line charts with string (ordinal) scale at point location
--   2016-01-28 For maps, always use wikiraw:// protocol. https:// will be disabled soon.

-- Module:Graph
local p = {}
local SVGRenderer = require('Module:Graph/SVGRenderer')
local TrackingCategory = require('Module:TrackingCategory')
local DataProc = require('Module:Graph/DataProcess')

local debuglog = "Debug \n\n"
local persistentGrey = "#54595d"

-- 原版內建符號形狀與路徑定義 (擴充支援 star, wye, ring, plus 與連字號三角形)
local shapes = DataProc.shapes
local parseBool = DataProc.parseBool
local parseShapeArray = DataProc.parseShapeArray
local parseShowValues = DataProc.parseShowValues
local numericArray = DataProc.numericArray
local stringArray = DataProc.stringArray
local maskStripMarkers = DataProc.maskStripMarkers
local stripJsonComments = DataProc.stripJsonComments
local splitTopLevelJsonObjects = DataProc.splitTopLevelJsonObjects
local translateVegaJsonToArgs = DataProc.translateVegaJsonToArgs
local wrangleRecordsToArgs = DataProc.wrangleRecordsToArgs
local fetchExternalData = DataProc.fetchExternalData
local deserializeXData = DataProc.deserializeXData
local deserializeYData = DataProc.deserializeYData
local extractRecordList = DataProc.extractRecordList
local buildColorAndAlphaPalette = DataProc.buildColorAndAlphaPalette

-- 優雅降級 (Fallback):回傳原生 <graph> 擴充標籤,讓系統顯示官方停用警告框
local function triggerFallback(frame, rawContent)
	TrackingCategory.append('SVG圖表有錯誤的頁面') --追蹤此類頁面
	local wf = (frame and frame.extensionTag) and frame or mw.getCurrentFrame()
	if wf and wf.extensionTag then
		local ok, res = pcall(function()
			return wf:extensionTag{ name = 'graph', content = tostring(rawContent or '{}') }
		end)
		if ok and res then return res end
	end
	return p.renderFallback()
end

--subst會壞掉,直接FallBack成舊版圖表不可用警示
function p.renderFallback()
	return require('Module:Message box').main( "ombox", { text = "由於[[phab:T405550|技術原因]],此[[mw:Extension:Graph|舊版圖表]]已停用。造成您的不便,我們深表歉意。" .. (mw.isSubsting() and "若您是因為替換引用而看到此消息,請將此改為模板調用,以維持圖表顯示。" or ""), nocat=true})
end



local function extractArgs(frame)
	if not frame then return {} end
	if type(frame) == "string" then
		return { [1] = frame }
	end
	if type(frame) == "table" and not frame.args and not frame.getParent then
		if frame.marks and frame.data then
			return { _vegaTable = frame }
		end
		return frame
	end
	local merged = {}
	local function copyArgs(src)
		if not src then return end
		for k, v in pairs(src) do
			-- 關鍵修復:若 JSON 因內部含有 !== 或 === 被 MediaWiki 當成「參數名=參數值」切開,自動縫合回 merged[1]
			if type(k) == "string" and string.match(mw.text.trim(k), "^%{") then
				merged[1] = maskStripMarkers(k .. "=" .. tostring(v))
			elseif v ~= "" then
				merged[k] = (type(v) == "string") and maskStripMarkers(v) or v
			end
		end
	end
	local parent = frame.getParent and frame:getParent()
	if parent then copyArgs(parent.args) end
	copyArgs(frame.args)
	return merged
end

-- 預處理外部資料源與 Vega JSON (回傳 shouldFallback 布林值)
local function prepareExternalDataAndVega(args, frame)
	if args._prepared then return false, nil end
	args._prepared = true

	-- 相容舊版 Graph 擴充功能的 content= 參數:
	-- 以 { 開頭視為 Vega JSON,否則視為資料頁名稱 / URL
	local contentArg = args.content
	if type(contentArg) == "string" and mw.text.trim(contentArg) ~= "" then
		local trimmed = mw.text.trim(contentArg)
		if string.match(trimmed, "^%{") then
			if not args.json and not args.vega then args.json = trimmed end
		else
			if not args.dataUrl and not args.dataurl and not args.url then
				args.dataUrl = trimmed
			end
		end
	end

	local rawJsonInput = args.json or args.vega or args[1]
	if args._vegaTable then
		translateVegaJsonToArgs(args._vegaTable, args)
		args._vegaTable = nil
	elseif type(rawJsonInput) == "string" and string.match(mw.text.trim(rawJsonInput), "^%{") then
		local ok, fallback = translateVegaJsonToArgs(rawJsonInput, args, frame)
		if fallback or not ok then
			return true, rawJsonInput
		end
	end

	-- [新增] 若確認為 Vega 地圖,直接中斷後續的折線圖資料轉換,交由外層發送給 MapRenderer
	if args._isVegaMap then
		return false, nil
	end

	if args._inlineRecords then
		-- [新增] 呼叫 Expression 模組處理 formula / filter 等資料變換
		if args._vegaTransforms then
			local Expr = require('Module:Graph/Expression')
			local transformed, shouldAbort = Expr.applyTransforms(args._inlineRecords, args._vegaTransforms, nil, args._vegaSignals)
			args._vegaTransforms = nil
			if shouldAbort or not transformed then return true, rawJsonInput end
			args._inlineRecords = transformed
		end

		local okWrangle = wrangleRecordsToArgs(args._inlineRecords, args)
		args._inlineRecords = nil
		if not okWrangle then return true, rawJsonInput end
		return false, nil
	end

	local dataUrl = args.dataUrl or args.dataurl or args.url
	if dataUrl and mw.text.trim(tostring(dataUrl)) ~= "" then
		local records, shouldFallback = fetchExternalData(dataUrl, frame)
		if shouldFallback or not records then
			return true, rawJsonInput or dataUrl
		end

		-- [新增] 呼叫 Expression 模組處理 formula / filter 等資料變換
		if args._vegaTransforms then
			local Expr = require('Module:Graph/Expression')
			local transformed, shouldAbort = Expr.applyTransforms(records, args._vegaTransforms, nil, args._vegaSignals)
			args._vegaTransforms = nil
			if shouldAbort or not transformed then
				return true, rawJsonInput or dataUrl
			end
			records = transformed
		end

		local okWrangle = wrangleRecordsToArgs(records, args)
		if not okWrangle then
			return true, rawJsonInput or dataUrl
		end
	end

	return false, nil
end

-- 解析 colorMap 參數陣列:支援方案 B (如 #000:#f00:#fff 或 #000;#f00;#fff)、中括號包裹 [#000, #f00, #fff] 與尾隨色碼自動合併
local function parseColorMapList(rawStr, nSeriesCount)
	if not rawStr or mw.text.trim(tostring(rawStr)) == "" then return nil end
	local s = mw.text.trim(tostring(rawStr))
	local bracketBlocks = {}
	local masked = string.gsub(s, "%[([^%]]+)%]", function(inner)
		bracketBlocks[#bracketBlocks + 1] = string.gsub(mw.text.trim(inner), "%s*,%s*", ":")
		return "__CMAP_BRACKET_" .. #bracketBlocks .. "__"
	end)
	local list = stringArray(masked)
	if not list then return nil end
	for i = 1, #list do
		local bIdx = tonumber(string.match(list[i], "^__CMAP_BRACKET_(%d+)__$"))
		if bIdx and bracketBlocks[bIdx] then
			list[i] = bracketBlocks[bIdx]
		end
	end

	-- 若只有單一數列且傳入多個逗號分隔色碼 (如 colorMap = #000, #f00, #fff),自動合併為單一自訂漸層色階
	if nSeriesCount <= 1 and #list >= 2 then
		local Utils = require('Module:Graph/RenderUtils')
		local firstClean = string.gsub(list[1], "^[%a]+%s*:%s*", "")
		if Utils.isValidColorToken(firstClean) and not Utils.isColormapName(list[1]) then
			return { table.concat(list, ":") }
		end
	end

	-- 若傳入項目數大於數列數 (如 3 個數列寫 colorMap = jet, blue, #000000, #ff0000, #ffffff),將尾隨色碼合併給最後一個數列
	if nSeriesCount > 1 and #list > nSeriesCount then
		local Utils = require('Module:Graph/RenderUtils')
		local tailColors = {}
		local allTailValid = true
		for i = nSeriesCount, #list do
			local cleanTk = (i == nSeriesCount) and string.gsub(list[i], "^[%a]+%s*:%s*", "") or list[i]
			if Utils.isValidColorToken(cleanTk) then
				tailColors[#tailColors + 1] = list[i]
			else
				allTailValid = false
				break
			end
		end
		if allTailValid and #tailColors >= 2 then
			local merged = {}
			for i = 1, nSeriesCount - 1 do merged[i] = list[i] end
			merged[nSeriesCount] = table.concat(tailColors, ":")
			return merged
		end
	end

	return list
end

function p.buildChartSpec(args, frame)
	prepareExternalDataAndVega(args, frame)

	-- 支援直接於 x 或 y 傳入 [[x, y, z], ...] 二維陣列字串
	local rawTupleStr = nil
	if type(args.x) == "string" and string.match(mw.text.trim(args.x), "^%[%s*%[") then
		rawTupleStr = mw.text.trim(args.x)
	elseif type(args.y) == "string" and string.match(mw.text.trim(args.y), "^%[%s*%[") then
		rawTupleStr = mw.text.trim(args.y)
		args.y = nil
	elseif type(args.y1) == "string" and string.match(mw.text.trim(args.y1), "^%[%s*%[") then
		rawTupleStr = mw.text.trim(args.y1)
		args.y1 = nil
	end
	if rawTupleStr then
		local okT, tupleList = pcall(mw.text.jsonDecode, stripJsonComments(rawTupleStr), mw.text.JSON_TRY_FIXING)
		if okT and type(tupleList) == "table" and type(tupleList[1]) == "table" then
			local tx, ty, tz = {}, {}, {}
			local hasZCol = false
			for idx = 1, #tupleList do
				local row = tupleList[idx]
				tx[idx] = tostring(row[1] or 0)
				ty[idx] = tostring(row[2] or 0)
				if row[3] ~= nil then
					tz[idx] = tostring(row[3])
					hasZCol = true
				else
					tz[idx] = ""
				end
			end
			args.x = table.concat(tx, ",")
			args.y1 = table.concat(ty, ",")
			if hasZCol and not args.z and not args.z1 then
				args.z1 = table.concat(tz, ",")
			end
		end
	end

	local graphwidth = tonumber(args.width) or 200
	local graphheight = tonumber(args.height) or 200
	local rawChartType = string.lower(mw.text.trim(tostring(args.type or "line")))
	local interpolate = args.interpolate and string.lower(mw.text.trim(tostring(args.interpolate))) or "linear"

	local colorString = args.colors
	local rawColors = colorString and stringArray(colorString) or nil

	local linewidth = tonumber(args.linewidth)
	local linewidthsString = args.linewidths
	local linewidths = (linewidthsString and linewidthsString ~= "") and numericArray(linewidthsString) or nil

	local xTitle = args.xAxisTitle or args.xaxistitle
	local yTitle = args.yAxisTitle or args.yaxistitle
	local chartTitle = args.title

	local xType = args.xType or args.xtype
	local yType = args.yType or args.ytype
	if xType then xType = string.lower(mw.text.trim(xType)) end
	if yType then yType = string.lower(mw.text.trim(yType)) end

	local xMin = args.xAxisMin or args.xaxismin
	local xMax = args.xAxisMax or args.xaxismax
	local yMin = args.yAxisMin or args.yaxismin
	local yMax = args.yAxisMax or args.yaxismax
	local domainMin = tonumber(args.domainMin or args.domainmin)
	local domainMax = tonumber(args.domainMax or args.domainmax)

	local xAxisFormat = args.xAxisFormat or args.xaxisformat
	local yAxisFormat = args.yAxisFormat or args.yaxisformat
	local xAxisAngle = tonumber(args.xAxisAngle or args.xaxisangle)

	local xScaleType = string.lower(mw.text.trim(tostring(args.xScaleType or args.xscaletype or "linear")))
	local yScaleType = string.lower(mw.text.trim(tostring(args.yScaleType or args.yscaletype or "linear")))

	local xGrid = parseBool(args.xGrid or args.xgrid, false)
	local yGrid = parseBool(args.yGrid or args.ygrid, false)

	local showSymbolsRaw = args.showSymbols or args.showsymbols
	local symbolsShapeRaw = args.symbolsShape or args.symbolsshape
	local symbolsNoFill = parseBool(args.symbolsNoFill or args.symbolsnofill, false)
	local symbolsStroke = tonumber(args.symbolsStroke or args.symbolsstroke)

	local legendRaw = args.legend
	local showLegend = false
	local legendTitle = nil
	if legendRaw ~= nil and (parseBool(legendRaw, true) ~= false or (type(legendRaw)==type("string") and mw.text.trim(legendRaw) == '')) then
		showLegend = true
		local trimmedLeg = mw.text.trim(tostring(legendRaw))
		if string.lower(trimmedLeg) ~= "true" and string.lower(trimmedLeg) ~= "yes" and string.lower(trimmedLeg) ~= "1" then
			legendTitle = trimmedLeg
		end
	elseif rawChartType == "heatmap" and not parseBool(args.hideAxes or args.hideaxes, false) and legendRaw == nil then
		showLegend = true
	end

	local showValues = parseShowValues(args.showValues or args.showvalues)

	local v_annoLineString = args.vAnnotatonsLine or args.vannotatonsline or args.vAnnotationsLine or args.vannotationsline
	local h_annoLineString = args.hAnnotatonsLine or args.hannotatonsline or args.hAnnotationsLine or args.hannotationsline
	local v_annoLabelString = args.vAnnotatonsLabel or args.vannotatonslabel or args.vAnnotationsLabel or args.vannotationslabel
	local h_annoLabelString = args.hAnnotatonsLabel or args.hannotatonslabel or args.hAnnotationsLabel or args.hannotationslabel

	local innerRadius = tonumber(args.innerRadius or args.innerradius) or 0
	local outerRadius = tonumber(args.outerRadius or args.outerradius)
	local startAngle = tonumber(args.startAngle or args.startangle or args.polarStartAngle or args.polarstartangle)

	-- 支援使用者傳入 x1, z1 作為 x 與 z 的別名,以及角度/弧度/轉數 (turn) 單位
	local rawXInput = args.x or args.x1
	local isAngleXType = (xType == "radian" or xType == "rad" or xType == "radians"
		or xType == "angle" or xType == "deg" or xType == "degree" or xType == "degrees"
		or xType == "turn" or xType == "turns")
	local x
	x, xType, xMin, xMax = deserializeXData(rawXInput, isAngleXType and "number" or xType, xMin, xMax)
	if isAngleXType then
		xType = string.lower(mw.text.trim(tostring(args.xType or args.xtype)))
	end

	local yValues = {}
	local rawTitles = {}
	local rawSeriesCmaps = {}
	local maxYNum = 0
	for name, value in pairs(args) do
		local yNum
		if name == "y" then
			yNum = 1
		elseif type(name) == "string" then
			yNum = tonumber(string.match(name, "^y(%d+)$"))
		end
		if yNum and value and mw.text.trim(tostring(value)) ~= "" then
			yValues[yNum] = value
			rawTitles[yNum] = args["y" .. yNum .. "Title"] or args["y" .. yNum .. "title"] or (yNum == 1 and (args.yTitle or args.ytitle)) or ("y" .. yNum)
			rawSeriesCmaps[yNum] = args["y" .. yNum .. "ColorMap"] or args["y" .. yNum .. "colormap"] or args["colorMap" .. yNum] or args["colormap" .. yNum]
			if yNum > maxYNum then maxYNum = yNum end
		end
	end

	if args.z1 and not args.z then
		args.z = args.z1
	end

	local compactYValues = {}
	local seriesTitles = {}
	local seriesColorMaps = {}
	for idx = 1, maxYNum do
		if yValues[idx] ~= nil then
			compactYValues[#compactYValues + 1] = yValues[idx]
			seriesTitles[#seriesTitles + 1] = rawTitles[idx]
			if rawSeriesCmaps[idx] then
				-- 方案 A (yNColorMap):若以逗號分隔多個色碼且未使用分號,自動轉為冒號分隔之漸層色階
				local sc = mw.text.trim(tostring(rawSeriesCmaps[idx]))
				if string.find(sc, ",", 1, true) and not string.find(sc, ";", 1, true) then
					sc = string.gsub(sc, "%s*,%s*", ":")
				end
				seriesColorMaps[#compactYValues] = sc
			end
		end
	end

	local y
	y, yType, yMin, yMax = deserializeYData(compactYValues, yType, yMin, yMax)

	-- 反序列化氣泡圖大小陣列 (size, size1..sizen) 與甘特圖結束座標 (x2)
	local sizes = {}
	local hasSizes = false
	for idx = 1, math.max(1, maxYNum) do
		local rawS = args["size" .. idx] or (idx == 1 and args.size) or nil
		if rawS and mw.text.trim(tostring(rawS)) ~= "" then
			local sArr = numericArray(rawS)
			if sArr then
				sizes[idx] = sArr
				hasSizes = true
			end
		end
	end
	if hasSizes and not showSymbolsRaw then
		showSymbolsRaw = "3.5"
	end

	local x2 = nil
	if args.x2 and mw.text.trim(tostring(args.x2)) ~= "" then
		x2 = deserializeXData(args.x2, xType, nil, nil)
	end

	local maxYCount = 0
	for i = 1, #y do
		local len = y[i].n or #y[i]
		if len > maxYCount then maxYCount = len end
	end
	local xCount = x.n or #x
	if xCount == 0 and maxYCount > 0 then
		for j = 1, maxYCount do x[j] = j end
		x.n = maxYCount
		xCount = maxYCount
		xType = "integer"
	end

	local stacked = false
	local polarHist = false
	local isHist = false
	local isPolarLine = false
	local chartType = rawChartType
	if string.sub(chartType, 1, 7) == "stacked" then
		chartType = string.sub(chartType, 8)
		if #y > 1 then stacked = true end
	end
	if chartType == "bar" or chartType == "groupedbar" or chartType == "groupedrect" then
		chartType = "rect"
	elseif chartType == "hist" or chartType == "histogram" or chartType == "groupedhist" then
		chartType = "rect"
		isHist = true
	elseif chartType == "polarbar" or chartType == "polarrect" or chartType == "rose" or chartType == "nightingale" or chartType == "coxcomb" or chartType == "groupedpolarbar" then
		chartType = "polarbar"
	elseif chartType == "polarhist" or chartType == "polarhistogram" then
		chartType = "polarbar"
		polarHist = true
		isHist = true
	elseif chartType == "bubble" or chartType == "scatter" then
		chartType = "line"
		linewidth = 0
		if not showSymbolsRaw then showSymbolsRaw = "3.5" end
	elseif chartType == "radar" or chartType == "spider" or chartType == "polararea" then
		chartType = "radar"
		if showSymbolsRaw == nil then showSymbolsRaw = "2.5" end
	elseif chartType == "polarline" then
		chartType = "radar"
		isPolarLine = true
		if showSymbolsRaw == nil then showSymbolsRaw = "2.5" end
	elseif chartType == "scatter3d" or chartType == "bubble3d" or chartType == "point3d" then
		chartType = "scatter3d"
		if showSymbolsRaw == nil then showSymbolsRaw = "4.0" end
	elseif chartType == "bar3d" or chartType == "rect3d" or chartType == "column3d" then
		chartType = "bar3d"
	elseif chartType == "surface3d" or chartType == "mesh3d" then
		chartType = "surface3d"
	elseif chartType == "line3d" then
		chartType = "line3d"
	end

	local is3D = (chartType == "scatter3d" or chartType == "bar3d" or chartType == "surface3d" or chartType == "line3d")
		or ((args.z ~= nil or args.z1 ~= nil) and chartType ~= "polarbar" and chartType ~= "rect")
	if is3D and chartType == "line" then
		chartType = (linewidth == 0) and "scatter3d" or "line3d"
	end

	-- 解析科學色階與自訂色階參數 (支援方案 A 與方案 B)
	local rawColorMap = args.colormap or args.colorMap or args.colorScale or args.colorscale
	local colorMap = nil
	local cmList = parseColorMapList(rawColorMap, math.max(1, #y))
	if cmList and #cmList > 0 then
		colorMap = cmList[1]
		for i = 1, math.max(1, #y) do
			seriesColorMaps[i] = seriesColorMaps[i] or cmList[(i - 1) % #cmList + 1]
		end
	end

	-- 反序列化 Z 軸資料 (供 3D 圖表高度、或 2D 極座標/直角長條圖 [x, y, z] 之條柱寬度使用)
	local z, zType, zMin, zMax = nil, nil, nil, nil
	local zTitle, zAxisFormat, zScaleType, zGrid = nil, nil, "linear", true
	local viewTheta, viewPhi, viewDistance = nil, nil, nil
	if is3D or chartType == "polarbar" or chartType == "rect" or args.z ~= nil or args.z1 ~= nil then
		zTitle = args.zAxisTitle or args.zaxistitle or args.zTitle or args.ztitle
		zAxisFormat = args.zAxisFormat or args.zaxisformat
		zScaleType = string.lower(mw.text.trim(tostring(args.zScaleType or args.zscaletype or "linear")))
		zGrid = parseBool(args.zGrid or args.zgrid, true)
		zType = args.zType or args.ztype
		if zType then zType = string.lower(mw.text.trim(zType)) end
		zMin = args.zAxisMin or args.zaxismin or args.zMin or args.zmin
		zMax = args.zAxisMax or args.zaxismax or args.zMax or args.zmax
		viewTheta = tonumber(args.view_theta or args.viewTheta or args.theta or args.azimuth)
		viewPhi = tonumber(args.view_phi or args.viewPhi or args.phi or args.elevation)
		viewDistance = tonumber(args.view_distance or args.viewDistance or args.distance)

		local zValues = {}
		local maxZNum = 0
		for name, value in pairs(args) do
			local zNum
			if name == "z" then
				zNum = 1
			elseif type(name) == "string" then
				zNum = tonumber(string.match(name, "^z(%d+)$"))
			end
			if zNum and value and mw.text.trim(tostring(value)) ~= "" then
				zValues[zNum] = value
				if zNum > maxZNum then maxZNum = zNum end
			end
		end
		if maxZNum > 0 then
			local compactZ = {}
			for idx = 1, maxZNum do
				if zValues[idx] ~= nil then compactZ[#compactZ + 1] = zValues[idx] end
			end
			z, zType, zMin, zMax = deserializeYData(compactZ, zType, zMin, zMax)
		else
			zMin = tonumber(zMin)
			zMax = tonumber(zMax)
		end
	end

	local dirStr = args.direction and string.lower(mw.text.trim(tostring(args.direction))) or nil
	local reverseY = parseBool(args.reverseY or args.reversey or args.yReverse or args.yreverse or args._yReverse, false)
		or (dirStr == "inward" or dirStr == "in" or dirStr == "reverse")
	local barPadding = tonumber(args.barPadding or args.barpadding or args.padding)
	local groupPadding = tonumber(args.groupPadding or args.grouppadding or args.innerPadding or args.innerpadding)

	local rawAlign = args.align or args.barAlign or args.baralign
	local barAlign = nil
	if rawAlign and mw.text.trim(tostring(rawAlign)) ~= "" then
		local al = string.lower(mw.text.trim(tostring(rawAlign)))
		if al == "center" or al == "middle" or al == "mid" then
			barAlign = "center"
		elseif al == "edge" or al == "left" or al == "start" then
			barAlign = "edge"
		end
	end
	if not barAlign then
		barAlign = ((z ~= nil and #z > 0) or isHist) and "edge" or "center"
	end

	local vAnnotations = {}
	if v_annoLineString and v_annoLineString ~= "" then
		local vLines = (xType == "number" or xType == "integer") and numericArray(v_annoLineString) or stringArray(v_annoLineString)
		local vLabels = stringArray(v_annoLabelString) or {}
		if vLines then
			for i = 1, (vLines.n or #vLines) do
				if vLines[i] ~= false and vLines[i] ~= nil then
					vAnnotations[#vAnnotations + 1] = { x = vLines[i], label = vLabels[i] or "" }
				end
			end
		end
	end

	local hAnnotations = {}
	if h_annoLineString and h_annoLineString ~= "" then
		local hLines = (yType == "number" or yType == "integer") and numericArray(h_annoLineString) or stringArray(h_annoLineString)
		local hLabels = stringArray(h_annoLabelString) or {}
		if hLines then
			for i = 1, (hLines.n or #hLines) do
				if hLines[i] ~= false and hLines[i] ~= nil then
					hAnnotations[#hAnnotations + 1] = { y = hLines[i], label = hLabels[i] or "" }
				end
			end
		end
	end

	local colors, alphas = buildColorAndAlphaPalette(rawColors, chartType, xCount, #y)

	if symbolsShapeRaw and showSymbolsRaw == nil then
		showSymbolsRaw = "3.5"
	end

	local symbols = nil
	if showSymbolsRaw and parseBool(showSymbolsRaw, true) ~= false and chartType ~= "rect" and chartType ~= "polarbar" then
		local symRadii = {}
		local numList = numericArray(showSymbolsRaw)
		if numList then
			for i = 1, (numList.n or #numList) do
				local v = tonumber(numList[i]) or 2.5
				symRadii[i] = math.max(1.5, v * 1.4)
			end
		elseif tonumber(showSymbolsRaw) then
			local r = math.max(1.5, tonumber(showSymbolsRaw) * 1.4)
			for i = 1, math.max(1, #y) do symRadii[i] = r end
		else
			for i = 1, math.max(1, #y) do symRadii[i] = 3.2 end
		end

		local shapeList = parseShapeArray(symbolsShapeRaw) or { "circle" }
		local resolvedShapes = {}
		for i = 1, math.max(1, #y) do
			local key = shapeList[(i - 1) % #shapeList + 1] or "circle"
			resolvedShapes[i] = shapes[key] or key
		end

		local defaultStroke = (symbolsNoFill or args.symbolsFillWhite or symbolsShapeRaw == "x") and 2.0 or 0
		symbols = {
			enabled = true,
			radii = symRadii,
			shapes = resolvedShapes,
			noFill = symbolsNoFill or args.symbolsFillWhite,
			strokeWidth = symbolsStroke or defaultStroke
		}
	end

	return {
		width = graphwidth,
		height = graphheight,
		chartType = chartType,
		isHist = isHist,
		polarHist = polarHist,
		isPolarLine = isPolarLine,
		stacked = stacked,
		reverseY = reverseY,
		barAlign = barAlign,
		barPadding = barPadding,
		groupPadding = groupPadding,
		startAngle = startAngle,
		interpolate = interpolate,
		colors = colors,
		alphas = alphas,
		colorMap = colorMap,
		seriesColorMaps = next(seriesColorMaps) and seriesColorMaps or nil,
		domainMin = domainMin,
		domainMax = domainMax,
		linewidth = linewidth,
		linewidths = linewidths,
		title = chartTitle,
		xTitle = xTitle,
		yTitle = yTitle,
		xType = xType,
		yType = yType,
		xMin = xMin,
		xMax = xMax,
		yMin = yMin,
		yMax = yMax,
		xAxisFormat = xAxisFormat,
		yAxisFormat = yAxisFormat,
		xAxisAngle = xAxisAngle,
		xScaleType = xScaleType,
		yScaleType = yScaleType,
		xGrid = xGrid,
		yGrid = yGrid,
		showLegend = showLegend,
		legendTitle = legendTitle,
		showValues = showValues,
		symbols = symbols,
		vAnnotations = vAnnotations,
		hAnnotations = hAnnotations,
		innerRadius = innerRadius,
		outerRadius = outerRadius,
		rangedRect = args._rangedRectSpec,
		heatmap = args._heatmapSpec,
		gantt = args._ganttSpec,
		boxPlot = args._boxPlotSpec,
		hideAxes = parseBool(args.hideAxes or args.hideaxes, false) or (args._isScatterGrid == true),
		isScatterGrid = (args._isScatterGrid == true),
		interactive = parseBool(args.interactive, true) and not (args._isScatterGrid == true),
		animate = parseBool(args.animate, true) and not (args._isScatterGrid == true),
		persistentGrey = persistentGrey,
		is3D = is3D,
		z = z,
		zTitle = zTitle,
		zType = zType,
		zMin = zMin,
		zMax = zMax,
		zAxisFormat = zAxisFormat,
		zScaleType = zScaleType,
		zGrid = zGrid,
		viewTheta = viewTheta,
		viewPhi = viewPhi,
		viewDistance = viewDistance,
		x = x,
		x2 = x2,
		y = y,
		sizes = hasSizes and sizes or nil,
		seriesTitles = seriesTitles
	}
end

function p.chart(frame)
	--subst必定不可用,直接短路到圖表不可用。
	if mw.isSubsting() then return p.renderFallback(frame) end
	local args = extractArgs(frame)
	local rawJsonInput = args.json or args.vega or args.content or args.code or args[1]

	-- 避免自訂形狀在json裡被誤抓
	if string.match('<!--svg-->'..(rawJsonInput or ''), "[^=,;%[%]\"'%s]%s*<[sS][vV][gG]%s*([^>]*)>") then
		--確定是一個svg後直接短路輸出
		return SVGRenderer.RenderSVG({args=args})
	end
	
	-- 定義內部渲染或降級處理函數,方便複用
	local function renderOrFallback(subArgs, rawInput)
		local shouldFallback, fallbackRaw = prepareExternalDataAndVega(subArgs, frame)
		if shouldFallback then
			return triggerFallback(frame, fallbackRaw or rawInput)
		end
		
		if subArgs._isVegaMap then
			return require('Module:Graph/MapRenderer').renderMap(subArgs)
		end
		
		local spec = p.buildChartSpec(subArgs, frame)
		
		-- 空圖表防呆檢查:若無特殊圖表結構,且 X 軸為空或 Y 軸無任何有效數據,自動降級為圖表不可用
		if not (spec.rangedRect or spec.boxPlot or spec.gantt or spec.heatmap) then
			local xCount = spec.x and (spec.x.n or #spec.x) or 0
			local hasValidY = false
			if type(spec.y) == "table" then
				for i = 1, #spec.y do
					local series = spec.y[i]
					local len = series.n or #series
					for j = 1, len do
						if series[j] ~= false and series[j] ~= nil and series[j] ~= "" then
							hasValidY = true
							break
						end
					end
					if hasValidY then break end
				end
			end
			if xCount == 0 or not hasValidY then
				return triggerFallback(frame, fallbackRaw or rawInput)
			end
		end
		return SVGRenderer.renderChart(spec)
	end

	-- 若一次傳入多個連寫的 JSON 物件,自動逐一渲染並串接輸出
	if type(rawJsonInput) == "string" and string.match(mw.text.trim(rawJsonInput), "^%{") then
		local blocks = splitTopLevelJsonObjects(stripJsonComments(rawJsonInput))
		if #blocks > 1 then
			local out = {}
			for i = 1, #blocks do
				local subArgs = { [1] = blocks[i] }
				out[#out + 1] = renderOrFallback(subArgs, blocks[i])
			end
			return table.concat(out, " ")
		end
	end

	return renderOrFallback(args, rawJsonInput)
end

function p.chartWrapper(frame)
	return p.chart(frame)
end

-- 支援直接傳入舊版 Vega JSON 進行渲染
function p.vega(frame)
	return p.chart(frame)
end

function p.chartDebuger(frame)
	local args = extractArgs(frame)
	local spec = p.buildChartSpec(args, frame)
	return "\n\nchart Spec\n " .. mw.dumpObject(spec) .. "\n\n" .. SVGRenderer.renderChart(spec) .. "\n\n" .. debuglog
end

function p.map(frame)
	--subst必定不可用,直接短路到圖表不可用。
	if mw.isSubsting() then return p.renderFallback(frame) end
	local args = extractArgs(frame)
	return require('Module:Graph/MapRenderer').renderMap(args)
end

function p.mapWrapper(frame)
	return p.map(frame)
end

function p.encodeTitleForPath(frame)
	local arg1 = (frame and frame.args and frame.args[1]) or (type(frame) == "table" and frame[1]) or ""
	return mw.uri.encode(mw.text.decode(mw.text.trim(tostring(arg1))), 'PATH')
end

-- 診斷用:逐步回報資料讀取在哪一步失敗
function p.dataDebug(frame)
	local args = extractArgs(frame)
	local out = {}
	local function say(s) out[#out + 1] = "* " .. s end

	local url = args.dataUrl or args.dataurl or args.url or args.content
	if not url or mw.text.trim(tostring(url)) == "" then
		return "* 沒有收到 dataUrl / url / content 參數"
	end
	url = mw.text.trim(tostring(url))
	say("收到的資料來源:<code>" .. mw.text.nowiki(url) .. "</code>")

	local pageName = url
	if string.sub(pageName, 1, 9) == "wikiraw:/" then
		pageName = string.gsub(pageName, "^wikiraw:///?", "")
		pageName = mw.uri.decode(pageName, "PATH")
	end
	pageName = mw.text.trim(pageName)

	local titleObj = mw.title.new(pageName)
	if not titleObj then
		say("mw.title.new 失敗:頁面名稱不合法")
		return table.concat(out, "\n")
	end
	say("頁面全名:<code>" .. mw.text.nowiki(titleObj.prefixedText) .. "</code>")
	say("頁面是否存在:" .. tostring(titleObj.exists))
	say("contentModel:" .. tostring(titleObj.contentModel))

	local content = titleObj:getContent()
	if not content or content == "" then
		say("getContent() 取得空內容(頁面不存在或內容為空)")
		return table.concat(out, "\n")
	end
	say("內容長度:" .. #content .. " 字元")

	local cleaned = string.gsub(content, "<noinclude>.-</noinclude>", "")
	cleaned = string.gsub(cleaned, "</?includeonly>", "")
	cleaned = string.gsub(cleaned, "</?onlyinclude>", "")
	local wf = (frame and frame.preprocess) and frame or mw.getCurrentFrame()
	if wf and wf.preprocess and string.find(cleaned, "{{", 1, true) then
		local okPre, preRes = pcall(function() return wf:preprocess(cleaned) end)
		if okPre and preRes then cleaned = preRes end
		say("內容含 {{ }},已嘗試展開模板")
	end
	cleaned = stripJsonComments(mw.text.trim(cleaned))

	local okDec, decoded = pcall(mw.text.jsonDecode, cleaned, mw.text.JSON_TRY_FIXING)
	if not okDec or type(decoded) ~= "table" then
		say("jsonDecode 失敗:<code>" .. mw.text.nowiki(tostring(decoded)) .. "</code>")
		return table.concat(out, "\n")
	end
	say("jsonDecode 成功")

	local list = extractRecordList(decoded)
	if not list then
		say("extractRecordList 回傳 nil:資料外層結構模組不認得")
		return table.concat(out, "\n")
	end

	local count = 0
	while list[count + 1] ~= nil do count = count + 1 end
	say("解析到的記錄筆數:" .. count)

	if list[1] and type(list[1]) == "table" then
		local rk = {}
		for k, v in pairs(list[1]) do
			rk[#rk + 1] = tostring(k) .. "=" .. tostring(v)
		end
		table.sort(rk)
		say("第一筆記錄:<code>" .. mw.text.nowiki(table.concat(rk, ", ")) .. "</code>")
	end

	local xField = args.xField or args.xfield or "x"
	local yField = args.yField or args.yfield or "y"
	local groupField = args.groupField or args.groupfield or args.seriesField or args.seriesfield
	say("使用的 xField:<code>" .. mw.text.nowiki(tostring(xField)) .. "</code>")
	say("使用的 yField:<code>" .. mw.text.nowiki(tostring(yField)) .. "</code>")
	say("使用的 groupField:<code>" .. mw.text.nowiki(tostring(groupField)) .. "</code>")

	local xFound, idx = 0, 1
	while list[idx] ~= nil do
		if list[idx][xField] ~= nil then xFound = xFound + 1 end
		idx = idx + 1
	end
	say("xField 有值的記錄數:" .. xFound .. " / " .. count)

	local testArgs = { xField = args.xField, yField = args.yField, groupField = args.groupField, type = args.type }
	local okW = wrangleRecordsToArgs(list, testArgs)
	say("wrangleRecordsToArgs 結果:" .. tostring(okW))
	if okW then
		local xs = stringArray(testArgs.x or "") or {}
		say("轉換後不重複 X 值數量:" .. #xs)
		say("X 範圍:<code>" .. mw.text.nowiki(tostring(xs[1]) .. " ~ " .. tostring(xs[#xs])) .. "</code>")
		say("y1Title:<code>" .. mw.text.nowiki(tostring(testArgs.y1Title)) .. "</code>")
		say("y2Title:<code>" .. mw.text.nowiki(tostring(testArgs.y2Title)) .. "</code>")
	end

	return table.concat(out, "\n")
end

return p