跳转到内容

模組:Graph/sandbox

维基百科,自由的百科全书
-- 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-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 debuglog = "Debug \n\n"
local persistentGrey = "#54595d"

-- 原版內建符號形狀與路徑定義
local shapes = {
	circle = "circle",
	x = "M-.5,-.5L.5,.5M.5,-.5L-.5,.5",
	square = "square",
	cross = "cross",
	diamond = "diamond",
	triangle_up = "triangle-up",
	triangle_down = "triangle-down",
	triangle_right = "triangle-right",
	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"
}

local PALETTE_CAT10 = {
	"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
	"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"
}
local PALETTE_CAT20 = {
	"#1f77b4", "#aec7e8", "#ff7f0e", "#ffbb78", "#2ca02c",
	"#98df8a", "#d62728", "#ff9896", "#9467bd", "#c5b0d5",
	"#8c564b", "#c49c94", "#e377c2", "#f7b6d2", "#7f7f7f",
	"#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"
}

-- 優雅降級 (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 '<div class="mw-graph-disabled">由於技術原因,此舊版圖表已停用。</div>'
end

-- 狀態機剝離 JSON 註解 (<!--...-->, //... 與 /*...*/)
local function stripJsonComments(str)
	str = string.gsub(tostring(str or ""), "<!%-%-.-%-%->", "")
	local out = {}
	local i, len = 1, #str
	local inStr = false
	while i <= len do
		local b = string.byte(str, i)
		if inStr then
			if b == 92 then
				out[#out + 1] = string.sub(str, i, i + 1)
				i = i + 2
			else
				if b == 34 then inStr = false end
				out[#out + 1] = string.char(b)
				i = i + 1
			end
		else
			if b == 34 then
				inStr = true
				out[#out + 1] = '"'
				i = i + 1
			elseif b == 47 then
				local nextB = string.byte(str, i + 1)
				if nextB == 47 then
					i = i + 2
					while i <= len and string.byte(str, i) ~= 10 and string.byte(str, i) ~= 13 do
						i = i + 1
					end
				elseif nextB == 42 then
					i = i + 2
					while i <= len do
						if string.byte(str, i) == 42 and string.byte(str, i + 1) == 47 then
							i = i + 2
							break
						end
						i = i + 1
					end
				else
					out[#out + 1] = '/'
					i = i + 1
				end
			else
				out[#out + 1] = string.char(b)
				i = i + 1
			end
		end
	end
local noComments = table.concat(out)
	-- 第二階段:在非字串區段修補模板空參數造成的 ": ," 空值,並剝除 ", ]" 或 ", }" 尾隨逗號
	local cleanOut = {}
	local j, nLen = 1, #noComments
	local inQuotes = false
	while j <= nLen do
		local b = string.byte(noComments, j)
		if inQuotes then
			if b == 92 then
				cleanOut[#cleanOut + 1] = string.sub(noComments, j, j + 1)
				j = j + 2
			else
				if b == 34 then inQuotes = false end
				cleanOut[#cleanOut + 1] = string.char(b)
				j = j + 1
			end
		else
			if b == 34 then
				inQuotes = true
				cleanOut[#cleanOut + 1] = '"'
				j = j + 1
			elseif b == 58 then -- ':' 檢查冒號後方是否因模板參數為空而直接遇到 ',' 或 '}' 或 ']'
				cleanOut[#cleanOut + 1] = ':'
				local k = j + 1
				while k <= nLen do
					local wb = string.byte(noComments, k)
					if wb ~= 32 and wb ~= 9 and wb ~= 13 and wb ~= 10 then break end
					k = k + 1
				end
				local nextByte = (k <= nLen) and string.byte(noComments, k) or nil
				if nextByte == 44 or nextByte == 125 or nextByte == 93 then -- ',' or '}' or ']'
					cleanOut[#cleanOut + 1] = 'null'
				end
				j = j + 1
			elseif b == 44 then -- ','
				local k = j + 1
				while k <= nLen do
					local wb = string.byte(noComments, k)
					if wb ~= 32 and wb ~= 9 and wb ~= 13 and wb ~= 10 then break end
					k = k + 1
				end
				local nextByte = (k <= nLen) and string.byte(noComments, k) or nil
				if nextByte == 93 or nextByte == 125 then -- ']' or '}'
					j = j + 1 -- 略過非法的尾隨逗號
				else
					cleanOut[#cleanOut + 1] = ','
					j = j + 1
				end
			else
				cleanOut[#cleanOut + 1] = string.char(b)
				j = j + 1
			end
		end
	end
	return table.concat(cleanOut)
end

-- 解析外部 CSV 資料表 (支援雙引號包裹含逗號欄位,如 "Korea, South")
local function parseCSV(text)
	if not text or text == "" then return nil end
	text = string.gsub(text, "\r\n", "\n")
	text = string.gsub(text, "\r", "\n")

	local function splitCSVLine(line)
		local res = {}
		local cur = {}
		local inQuotes = false
		local i, len = 1, #line
		while i <= len do
			local b = string.byte(line, i)
			if inQuotes then
				if b == 34 then -- '"'
					if string.byte(line, i + 1) == 34 then
						cur[#cur + 1] = '"'
						i = i + 2
					else
						inQuotes = false
						i = i + 1
					end
				else
					cur[#cur + 1] = string.char(b)
					i = i + 1
				end
			else
				if b == 34 then
					inQuotes = true
					i = i + 1
				elseif b == 44 then -- ','
					res[#res + 1] = mw.text.trim(table.concat(cur))
					cur = {}
					i = i + 1
				else
					cur[#cur + 1] = string.char(b)
					i = i + 1
				end
			end
		end
		res[#res + 1] = mw.text.trim(table.concat(cur))
		return res
	end

	local lines = mw.text.split(text, "\n", true)
	local headers = nil
	local records = {}

	for lIdx = 1, #lines do
		local line = mw.text.trim(lines[lIdx])
		if line ~= "" and string.sub(line, 1, 1) ~= "#" then
			if not headers then
				headers = splitCSVLine(line)
			else
				local vals = splitCSVLine(line)
				local rec = {}
				for cIdx = 1, #headers do
					local h = headers[cIdx]
					if h and h ~= "" then
						local v = vals[cIdx] or ""
						local numV = tonumber(v)
						-- 若為純數值且非 ID 欄位,轉為 number 方便後續運算
						if numV ~= nil and h ~= "id" and h ~= "ISO3" then
							rec[h] = numV
						else
							rec[h] = v
						end
					end
				end
				records[#records + 1] = rec
			end
		end
	end

	return (#records > 0) and records or nil
end

-- 若一次傳入多個連寫的頂層 JSON 物件,將其安全切分為獨立的 JSON 字串陣列
local function splitTopLevelJsonObjects(str)
	local blocks = {}
	local i, len = 1, #str
	local depth = 0
	local startIdx = nil
	local inStr = false

	while i <= len do
		local b = string.byte(str, i)
		if inStr then
			if b == 92 then
				i = i + 2
			else
				if b == 34 then inStr = false end
				i = i + 1
			end
		else
			if b == 34 then
				inStr = true
			elseif b == 123 then -- '{'
				if depth == 0 then startIdx = i end
				depth = depth + 1
			elseif b == 125 then -- '}'
				depth = depth - 1
				if depth == 0 and startIdx then
					blocks[#blocks + 1] = string.sub(str, startIdx, i)
					startIdx = nil
				end
			end
			i = i + 1
		end
	end
	return blocks
end

-- 從巢狀或 Metatable 代理表中提取資料陣列 (支援 formatSpec.property == "fields" 與欄位別名映射)
local function extractRecordList(rawObj, formatSpec)
	if type(rawObj) ~= "table" then return nil end
	local reqProp = type(formatSpec) == "table" and formatSpec.property or nil

	-- 若 Vega 明確要求讀取 schema.fields (如 Template:Graph:Lines 的 labels 資料源)
	if reqProp == "fields" and type(rawObj.schema) == "table" and type(rawObj.schema.fields) == "table" then
		local outFields = {}
		local fIdx = 1
		while rawObj.schema.fields[fIdx] ~= nil do
			local f = rawObj.schema.fields[fIdx]
			local tVal = f.title
			if type(tVal) == "table" then
				tVal = tVal.zh or tVal["zh-hant"] or tVal["zh-hans"] or tVal.en or next(tVal) and select(2, next(tVal))
			end
			outFields[fIdx] = {
				name = f.name or tostring(fIdx),
				title = tostring(tVal or f.name or fIdx)
			}
			fIdx = fIdx + 1
		end
		return outFields
	end

	if rawObj[1] ~= nil then return rawObj end

	-- 常見的外層鍵名(statistics 為 Template:Unblock-zh backlog statistics/data.json 所用)
	local knownKeys = { "statistics", "records", "rows", "items" }
	for _, k in ipairs(knownKeys) do
		local v = rawObj[k]
		if type(v) == "table" and v[1] ~= nil and type(v[1]) == "table" then
			return v
		end
	end

	if type(rawObj.data) == "table" then
		-- 檢查是否為 Commons Tabular Data 格式 (含 schema.fields 與二維陣列/字典 data)
		if type(rawObj.schema) == "table" and type(rawObj.schema.fields) == "table" then
			local fields = {}
			local titles = {}
			local fIdx = 1
			while rawObj.schema.fields[fIdx] ~= nil do
				local f = rawObj.schema.fields[fIdx]
				local fName = f.name or tostring(fIdx)
				fields[fIdx] = fName
				local tVal = f.title
				if type(tVal) == "table" then
					tVal = tVal.en or tVal.zh or tVal["zh-hant"]
				end
				if type(tVal) == "string" and tVal ~= "" and tVal ~= fName then
					titles[fIdx] = tVal
				end
				fIdx = fIdx + 1
			end
			local records = {}
			local rIdx = 1
			while rawObj.data[rIdx] ~= nil do
				local row = rawObj.data[rIdx]
				local rec = {}
				for cIdx = 1, #fields do
					local fName = fields[cIdx]
					local val = row[cIdx]
					if val == nil then val = row[fName] end
					rec[fName] = val
					-- 同步建立「底線轉空白」與「空白轉底線」的雙向別名 (如 New_Zealand <-> New Zealand)
					local spaceAlias = string.gsub(fName, "_", " ")
					if spaceAlias ~= fName and rec[spaceAlias] == nil then
						rec[spaceAlias] = val
					end
					local underAlias = string.gsub(fName, " ", "_")
					if underAlias ~= fName and rec[underAlias] == nil then
						rec[underAlias] = val
					end
					-- 同步建立 schema.fields[].title 別名
					local tAlias = titles[cIdx]
					if tAlias and rec[tAlias] == nil then
						rec[tAlias] = val
					end
				end
				records[rIdx] = rec
				rIdx = rIdx + 1
			end
			return records
		end
		if rawObj.data[1] ~= nil then
			if type(rawObj.data[1]) == "table" and rawObj.data[1].values then
				return rawObj.data[1].values
			end
			return rawObj.data
		end
	end
	if type(rawObj.values) == "table" and rawObj.values[1] ~= nil then
		return rawObj.values
	end
	if type(rawObj.series) == "table" and rawObj.series[1] ~= nil then
		return rawObj.series
	end

	-- 通用備案:依鍵名排序,取第一個「陣列且元素為記錄表」的欄位
	local keys = {}
	for k, v in pairs(rawObj) do
		if type(k) == "string" and type(v) == "table" and v[1] ~= nil and type(v[1]) == "table" then
			keys[#keys + 1] = k
		end
	end
	table.sort(keys)
	if #keys > 0 then
		return rawObj[keys[1]]
	end

	return nil
end

-- 5 階段外部資料路由器 (Data Router)
local function fetchExternalData(rawUrl, frame, formatSpec)
	if not rawUrl or mw.text.trim(tostring(rawUrl)) == "" then
		return nil, false
	end
	local url = mw.text.trim(tostring(rawUrl))

	-- 【路由 A】wikirest:// 或外部動態 HTTP API -> 直接觸發 Fallback
	if string.match(url, "^wikirest://") or string.match(url, "^https?://") or string.match(url, "^//") then
		return nil, true
	end

	-- 【路由 A2】wikidatasparql:// 維基數據 SPARQL 輕量直譯路由 (延遲載入 Module:Graph/Wikidata)
	if string.match(url, "^wikidatasparql:/") then
		local okW, Wikidata = pcall(require, "Module:Graph/Wikidata")
		if okW and type(Wikidata) == "table" and Wikidata.query then
			local records, shouldAbort = Wikidata.query(url)
			if not shouldAbort and type(records) == "table" and #records > 0 then
				return records, false
			end
		end
		return nil, true
	end

	-- 清理 wikiraw:///、tabular:///、map:/// 前綴並還原 URL 編碼路徑
	local pageName = url
	local isTabularProtocol = false
	if string.sub(pageName, 1, 9) == "wikiraw:/" then
		pageName = string.gsub(pageName, "^wikiraw:///?", "")
		pageName = mw.uri.decode(pageName, "PATH")
	elseif string.match(pageName, "^tabular:/") or string.match(pageName, "^map:/") then
		pageName = string.gsub(pageName, "^tabular:///?", "")
		pageName = string.gsub(pageName, "^map:///?", "")
		pageName = mw.uri.decode(pageName, "PATH")
		isTabularProtocol = true
	end
	pageName = mw.text.trim(pageName)

	-- 【路由 B】維基共享資源 Data: 命名空間 (mw.ext.data.get,若失敗則繼續往路由 D/E 尋找本地 Data: 頁面)
	if isTabularProtocol or string.match(pageName, "^[Cc]ommons:[Dd]ata:") or string.match(pageName, "^[Dd]ata:") then
		local cleanDataPage = string.gsub(pageName, "^[Cc]ommons:[Dd]ata:", "")
		cleanDataPage = string.gsub(cleanDataPage, "^[Dd]ata:", "")
		local spaceDataPage = string.gsub(cleanDataPage, "_", " ")
		if mw.ext and mw.ext.data and mw.ext.data.get then
			local ok, tabData = pcall(mw.ext.data.get, spaceDataPage)
			if not ok or type(tabData) ~= "table" then
				ok, tabData = pcall(mw.ext.data.get, cleanDataPage)
			end
			if ok and type(tabData) == "table" then
				local list = extractRecordList(tabData, formatSpec)
				if list then return list, false end
			end
		end
		pageName = "Data:" .. cleanDataPage
	end

	-- 【路由 C】Module: 命名空間 (mw.loadData)
	if string.match(pageName, "^[Mm]odule:") or string.match(pageName, "^模組:") or string.match(pageName, "^模块:") then
		local ok, modData = pcall(mw.loadData, pageName)
		if not ok or type(modData) ~= "table" then
			ok, modData = pcall(require, pageName)
		end
		if ok and type(modData) == "table" then
			local list = extractRecordList(modData, formatSpec)
			if list then return list, false end
		end
		return nil, true
	end

	-- 取得頁面 Title 物件,檢查【路由 D】與【路由 E】
	local titleObj = mw.title.new(pageName)
	if not titleObj then return nil, true end

	-- 【路由 D】檢查 contentModel === 'json',優先使用 mw.loadJsonData 快取通道
	if titleObj.contentModel == "json" and mw.loadJsonData then
		local ok, jsonData = pcall(mw.loadJsonData, pageName)
		if ok and type(jsonData) == "table" then
			local list = extractRecordList(jsonData, formatSpec)
			if list then return list, false end
		end
	end

	-- 【路由 E】一般維基頁面:getContent() -> preprocess 展開模板 -> 支援 CSV 或 JSON
	local content = titleObj:getContent()
	if not content or content == "" then return nil, true end

	content = string.gsub(content, "<noinclude>.-</noinclude>", "")
	content = string.gsub(content, "</?includeonly>", "")
	content = string.gsub(content, "</?onlyinclude>", "")

	local wf = (frame and frame.preprocess) and frame or mw.getCurrentFrame()
	if wf and wf.preprocess and string.find(content, "{{", 1, true) then
		local okPre, preRes = pcall(function() return wf:preprocess(content) end)
		if okPre and preRes then content = preRes end
	end

	local isCsvFormat = (type(formatSpec) == "table" and string.lower(tostring(formatSpec.type or "")) == "csv")
		or string.match(pageName, "[%-%.]csv$") ~= nil

	if isCsvFormat then
		local csvList = parseCSV(mw.text.trim(content))
		if csvList then return csvList, false end
	end

	local cleanJson = stripJsonComments(mw.text.trim(content))
	local okDec, decoded = pcall(mw.text.jsonDecode, cleanJson, mw.text.JSON_TRY_FIXING)
	if okDec and type(decoded) == "table" then
		local list = extractRecordList(decoded)
		if list then return list, false end
	end

	-- 若未標記 csv 但 JSON 解析失敗,嘗試當作 CSV 解析
	if not isCsvFormat then
		local csvList = parseCSV(mw.text.trim(content))
		if csvList then return csvList, false end
	end

	return nil, true
end

-- 智慧欄位讀取器:支援一般鍵名,以及 Vega 的 0-based 陣列索引 ("0", "1", "2" 或 "data.0")
local function getRecordField(row, field)
	if type(row) ~= "table" or field == nil then return nil end
	if row[field] ~= nil then return row[field] end
	local idx = tonumber(field)
	if idx == nil and type(field) == "string" then
		idx = tonumber(string.match(field, "^data%.(%d+)$"))
	end
	if idx ~= nil then
		if row[1] ~= nil and row[0] == nil then
			return row[idx + 1]
		end
		return row[idx]
	end
	return nil
end

-- 第三階段:資料變換與對齊 (將外部 JSON 紀錄轉置為 args.x, args.y1..yn, args.size1..sizen)
local function wrangleRecordsToArgs(records, args)
	local firstRec = records[1]
	if not firstRec then return false end

	local isPie = (string.lower(tostring(args.type or "")) == "pie")
	local xField = args.xField or args.xfield
	local yField = args.yField or args.yfield
	local sizeField = args.sizeField or args.sizefield

	-- 自動推斷欄位名稱 (支援 x/y、idx/val 與 0-based 純陣列)
	if not xField then
		if firstRec.x ~= nil then xField = "x"
		elseif firstRec.idx ~= nil then xField = "idx"
		elseif firstRec.name ~= nil then xField = "name"
		elseif firstRec.label ~= nil then xField = "label"
		elseif firstRec[1] ~= nil then xField = "0"
		else xField = "x" end
	end
	if not yField then
		if firstRec.y ~= nil then yField = "y"
		elseif firstRec.val ~= nil then yField = "val"
		elseif firstRec.value ~= nil then yField = "value"
		elseif firstRec[2] ~= nil then yField = "1"
		else yField = "y" end
	end

	-- 圓餅圖為單一數列,不進行 groupField 多數列拆分
	local groupField = nil
	if not isPie then
		groupField = args.groupField or args.groupfield or args.seriesField or args.seriesfield
		if not groupField then
			if firstRec.c ~= nil then groupField = "c"
			elseif firstRec.series ~= nil then groupField = "series"
			elseif firstRec.group ~= nil then groupField = "group"
			elseif firstRec[3] ~= nil and tostring(sizeField) ~= "2" then groupField = "2" end
		end
	end

	local xSet, xList = {}, {}
	local allXNumeric = true
	local gSet, gList = {}, {}
	local allGNumeric = true
	local pairSeen = {}
	local hasDuplicateXG = false

	-- 收集所有 X 值與群組名稱,並偵測是否為同 X 多點的散佈/網格資料
	local idx = 1
	while records[idx] ~= nil do
		local row = records[idx]
		local xv = getRecordField(row, xField)
		local gv = groupField and getRecordField(row, groupField) or nil
		if xv ~= nil then
			local xKey = tostring(xv)
			if not xSet[xKey] then
				xSet[xKey] = true
				xList[#xList + 1] = xKey
				if tonumber(xKey) == nil then allXNumeric = false end
			end
			local pairKey = xKey .. "\0" .. tostring(gv or "")
			if pairSeen[pairKey] then
				hasDuplicateXG = true
			else
				pairSeen[pairKey] = true
			end
		end
		if gv ~= nil then
			local gKey = tostring(gv)
			if not gSet[gKey] then
				gSet[gKey] = true
				gList[#gList + 1] = gKey
				if tonumber(gKey) == nil then allGNumeric = false end
			end
		end
		idx = idx + 1
	end

	if #xList == 0 then return false end

	-- 若為方形散佈網格 (如 QR Code)、氣泡散佈圖或同一 (x, group) 有多個不同 Y 點,啟用逐點對齊模式
	local usePointwiseMode = (args._isScatterGrid == true) or (hasDuplicateXG and not isPie)

	if groupField and #gList > 0 and allGNumeric then
		table.sort(gList, function(a, b) return tonumber(a) < tonumber(b) end)
	end

	-- 若 Vega Scale 有明確定義 domain 對應 range (如 qr 色盤),依實際出現的群組重組對應顏色
	if groupField and #gList > 0 and type(args._scaleColorMap) == "table" then
		local mappedColors = {}
		for gIdx = 1, #gList do
			local c = args._scaleColorMap[gList[gIdx]]
			if c then mappedColors[#mappedColors + 1] = c end
		end
		if #mappedColors == #gList then
			args.colors = table.concat(mappedColors, ",")
		end
	end

	if usePointwiseMode then
		local ptXList = {}
		local ptCount = 0
		local gIndexMap = {}
		local matrix = {}
		local sizeMatrix = {}
		local numGroups = math.max(1, #gList)

		for gIdx = 1, numGroups do
			if gList[gIdx] then gIndexMap[gList[gIdx]] = gIdx end
			matrix[gIdx] = {}
			sizeMatrix[gIdx] = {}
		end

		local rIdx = 1
		while records[rIdx] ~= nil do
			local row = records[rIdx]
			local xv = getRecordField(row, xField)
			local yv = getRecordField(row, yField)
			local sv = sizeField and getRecordField(row, sizeField) or nil
			if xv ~= nil and yv ~= nil then
				ptCount = ptCount + 1
				ptXList[ptCount] = string.gsub(tostring(xv), ",", "\\,")
				local gi = 1
				if groupField and #gList > 0 then
					local gv = getRecordField(row, groupField)
					gi = (gv ~= nil and gIndexMap[tostring(gv)]) or 1
				end
				for gIdx = 1, numGroups do
					matrix[gIdx][ptCount] = (gIdx == gi) and tostring(yv) or ""
					if sizeField then
						sizeMatrix[gIdx][ptCount] = (gIdx == gi and sv ~= nil) and tostring(sv) or ""
					end
				end
			end
			rIdx = rIdx + 1
		end

		if ptCount == 0 then return false end
		args.x = table.concat(ptXList, ",")
		for gIdx = 1, numGroups do
			args["y" .. gIdx] = table.concat(matrix[gIdx], ",")
			if sizeField then
				args["size" .. gIdx] = table.concat(sizeMatrix[gIdx], ",")
			end
			if not args["y" .. gIdx .. "Title"] then
				args["y" .. gIdx .. "Title"] = gList[gIdx] or yField
			end
		end
		return true
	end

	-- 圓餅圖與一般文字類別保留原始出現順序;純數值或 ISO 日期才做升冪排序
	if not isPie then
		if allXNumeric then
			table.sort(xList, function(a, b) return tonumber(a) < tonumber(b) end)
		elseif string.match(xList[1], "^%d%d%d%d[%-%/]%d%d") then
			table.sort(xList)
		end
	end

	local xIndexMap = {}
	local escapedXList = {}
	for i = 1, #xList do
		xIndexMap[xList[i]] = i
		escapedXList[i] = string.gsub(xList[i], ",", "\\,")
	end

	args.x = table.concat(escapedXList, ",")

	if groupField and #gList > 0 then
		local gIndexMap = {}
		local matrix = {}
		local sizeMatrix = {}
		for gIdx = 1, #gList do
			gIndexMap[gList[gIdx]] = gIdx
			local col, sCol = {}, {}
			for xIdx = 1, #xList do col[xIdx] = ""; sCol[xIdx] = "" end
			matrix[gIdx] = col
			sizeMatrix[gIdx] = sCol
		end

		local rIdx = 1
		while records[rIdx] ~= nil do
			local row = records[rIdx]
			local xv = getRecordField(row, xField)
			local gv = getRecordField(row, groupField)
			local yv = getRecordField(row, yField)
			local sv = sizeField and getRecordField(row, sizeField) or nil
			if xv ~= nil and gv ~= nil and yv ~= nil then
				local xi = xIndexMap[tostring(xv)]
				local gi = gIndexMap[tostring(gv)]
				if xi and gi then
					matrix[gi][xi] = tostring(yv)
					if sizeField and sv ~= nil then
						sizeMatrix[gi][xi] = tostring(sv)
					end
				end
			end
			rIdx = rIdx + 1
		end

		for gIdx = 1, #gList do
			args["y" .. gIdx] = table.concat(matrix[gIdx], ",")
			if sizeField then
				args["size" .. gIdx] = table.concat(sizeMatrix[gIdx], ",")
			end
			if not args["y" .. gIdx .. "Title"] then
				args["y" .. gIdx .. "Title"] = gList[gIdx]
			end
		end
		if args.legend == nil and #gList > 1 and not args._fromVegaJson then
			args.legend = ""
		end
	else
		-- 無分組或寬表格模式 (y1Field, y2Field 或單一 yField)
		local yFields = {}
		local fIdx = 1
		while args["y" .. fIdx .. "Field"] or args["y" .. fIdx .. "field"] do
			yFields[fIdx] = args["y" .. fIdx .. "Field"] or args["y" .. fIdx .. "field"]
			fIdx = fIdx + 1
		end
		if #yFields == 0 then yFields[1] = yField end

		for yfIdx = 1, #yFields do
			local colName = yFields[yfIdx]
			local col, sCol = {}, {}
			for xIdx = 1, #xList do col[xIdx] = ""; sCol[xIdx] = "" end
			local rIdx = 1
			while records[rIdx] ~= nil do
				local row = records[rIdx]
				local xv = getRecordField(row, xField)
				local yv = getRecordField(row, colName)
				local sv = (yfIdx == 1 and sizeField) and getRecordField(row, sizeField) or nil
				if xv ~= nil and yv ~= nil then
					local xi = xIndexMap[tostring(xv)]
					if xi then
						col[xi] = tostring(yv)
						if sv ~= nil then sCol[xi] = tostring(sv) end
					end
				end
				rIdx = rIdx + 1
			end
			args["y" .. yfIdx] = table.concat(col, ",")
			if yfIdx == 1 and sizeField then
				args.size1 = table.concat(sCol, ",")
			end
			if not args["y" .. yfIdx .. "Title"] then
				args["y" .. yfIdx .. "Title"] = colName
			end
		end
	end

	return true
end

-- 自動解析原生 Vega JSON 規格並轉譯為 Module:Graph 參數表
local function translateVegaJsonToArgs(jsonInput, args, frame)
	local vega
	if type(jsonInput) == "table" then
		vega = jsonInput
	else
		local cleaned = stripJsonComments(tostring(jsonInput))
		local ok, dec = pcall(mw.text.jsonDecode, cleaned, mw.text.JSON_TRY_FIXING)
		if not ok or type(dec) ~= "table" then return false, true end
		vega = dec
	end

	args.width = tonumber(args.width) or tonumber(vega.width) or 400
	args.height = tonumber(args.height) or tonumber(vega.height) or 200
	if vega.background then args.background = vega.background end
	args._fromVegaJson = true

	-- 0. 提取 Vega signals 初始值 (預先注入 width 與 height 供公式計算)
	args._vegaSignals = args._vegaSignals or {}
	args._vegaSignals.width = args.width
	args._vegaSignals.height = args.height
	if type(vega.signals) == "table" then
		local Expr = require('Module:Graph/Expression')
		for _, sig in ipairs(vega.signals) do
			if type(sig.name) == "string" then
				local initVal = sig.init ~= nil and sig.init or sig.value
				if type(initVal) == "table" then
					if type(initVal.expr) == "string" then
						local okE, evalVal = Expr.evaluate(initVal.expr, { signals = args._vegaSignals })
						if okE then args._vegaSignals[sig.name] = evalVal end
					end
				elseif initVal ~= nil then
					args._vegaSignals[sig.name] = initVal
				elseif type(sig.expr) == "string" then
					local okE, evalVal = Expr.evaluate(sig.expr, { signals = args._vegaSignals })
					if okE then args._vegaSignals[sig.name] = evalVal end
				end
			end
		end
	end

	-- 1. 依序解析整個 vega.data 陣列
	local datasets = {}
	local mapLookupSource = nil

	if type(vega.data) == "table" then
		for dIdx, dEntry in ipairs(vega.data) do
			local dName = dEntry.name or ("data_" .. dIdx)
			local isTopoMap = (type(dEntry.format) == "table" and dEntry.format.type == "topojson")
				or (type(dEntry.url) == "string" and string.match(dEntry.url, "^map:/") ~= nil)

			if type(dEntry.transform) == "table" then
				for _, tf in ipairs(dEntry.transform) do
					if tf.type == "geopath" then isTopoMap = true end
				end
			end

			if isTopoMap then
				args._isVegaMap = true
				args.basemap = dEntry.url or args.basemap
				if dEntry.format and dEntry.format.feature then
					args.feature = dEntry.format.feature
				end
				if type(dEntry.transform) == "table" then
					for _, tf in ipairs(dEntry.transform) do
						if tf.type == "geopath" then
							if tonumber(tf.scale) then args.scale = tonumber(tf.scale) end
							if type(tf.projection) == "string" then args.projection = tf.projection end
						elseif tf.type == "lookup" and type(tf.on) == "string" then
							mapLookupSource = tf.on
						end
					end
				end
			else
				local records = nil
				if type(dEntry.values) == "table" then
					records = dEntry.values
				elseif type(dEntry.source) == "string" and datasets[dEntry.source] then
					records = datasets[dEntry.source]
				elseif type(dEntry.url) == "string" then
					local shouldAbort
					records, shouldAbort = fetchExternalData(dEntry.url, frame, dEntry.format)
					if shouldAbort and dIdx == 1 then
						return false, true
					end
				end

				if dIdx == 1 and dEntry.format and type(dEntry.format.parse) == "table" then
					for _, pType in pairs(dEntry.format.parse) do
						if pType == "date" then
							args.xType = "date"
							break
						end
					end
				end

				if records and type(dEntry.transform) == "table" then
					for _, tf in ipairs(dEntry.transform) do
						if tf.type == "pie" and type(tf.field) == "string" then
							args.type = "pie"
							args.yField = tf.field
						elseif tf.type == "formula" and type(tf.field) == "string" and tf.field ~= "_xfield" then
							args.yField = tf.field
						end
					end
					local Expr = require('Module:Graph/Expression')
					local transformed, shouldAbort = Expr.applyTransforms(records, dEntry.transform, datasets, args._vegaSignals)
					if not shouldAbort and transformed then
						records = transformed
					end
				end

				if records then
					datasets[dName] = records
					if dIdx == 1 then args._inlineRecords = records end
				end
			end
		end
	end

	-- 2. 解析 scales 區塊
	local colorDomainField = nil
	local colorRampDomain, colorRampRange = nil, nil
	local hasPrimaryColorScale = false
	if type(vega.scales) == "table" then
		for _, sc in ipairs(vega.scales) do
			local sName = tostring(sc.name or "")
			if sName == "x" or string.match(sName, "^x[A-Z]") then
				if sc.type == "time" then args.xType = "date"
				elseif sc.type == "ordinal" and sName == "x" then args.xType = "string"
				elseif type(sc.type) == "string" and sName == "x" then args.xScaleType = sc.type end
				if sName == "x" then
					if sc.domainMin ~= nil and not args.xAxisMin then args.xAxisMin = sc.domainMin end
					if sc.domainMax ~= nil and not args.xAxisMax then args.xAxisMax = sc.domainMax end
				end
				if type(sc.domain) == "table" then
					if type(sc.domain.field) == "string" then
						args.xField = args.xField or sc.domain.field
					elseif tonumber(sc.domain[1]) and tonumber(sc.domain[2]) then
						args._vegaXMin = tonumber(sc.domain[1])
						args._vegaXMax = tonumber(sc.domain[2])
					end
				end
			elseif sName == "y" or string.match(sName, "^y[A-Z]") then
				if type(sc.type) == "string" and sName == "y" then args.yScaleType = sc.type end
				if sName == "y" then
					if tonumber(sc.domainMin) and not args.yAxisMin then args.yAxisMin = tonumber(sc.domainMin) end
					if tonumber(sc.domainMax) and not args.yAxisMax then args.yAxisMax = tonumber(sc.domainMax) end
				end
				if type(sc.domain) == "table" then
					if type(sc.domain.field) == "string" and sName == "y" then
						args.yField = sc.domain.field
					elseif tonumber(sc.domain[1]) and tonumber(sc.domain[2]) then
						args._vegaYMin = tonumber(sc.domain[1])
						args._vegaYMax = tonumber(sc.domain[2])
					end
				end
			elseif sName == "color" or sName == "c" or (not hasPrimaryColorScale and type(sc.range) == "table" and not string.match(sName, "^[xy]")) then
				if sName == "color" or sName == "c" then
					hasPrimaryColorScale = true
				end
				if type(sc.type) == "string" then args.scaleType = sc.type end
				if tonumber(sc.domainMin) then args.domainMin = tonumber(sc.domainMin) end
				if tonumber(sc.domainMax) then args.domainMax = tonumber(sc.domainMax) end
				if type(sc.domain) == "table" then
					if type(sc.domain.field) == "string" then
						colorDomainField = sc.domain.field
					else
						if tonumber(sc.domain[1]) and not args.domainMin then args.domainMin = tonumber(sc.domain[1]) end
						if tonumber(sc.domain[#sc.domain]) and not args.domainMax then args.domainMax = tonumber(sc.domain[#sc.domain]) end
						if type(sc.range) == "table" and #sc.domain > 0 and #sc.range > 0 then
							if sc.type == "linear" and #sc.domain >= 2 then
								colorRampDomain = sc.domain
								colorRampRange = sc.range
							else
								args._scaleColorMap = {}
								for dIdx = 1, #sc.domain do
									local cVal = sc.range[(dIdx - 1) % #sc.range + 1]
									args._scaleColorMap[tostring(sc.domain[dIdx])] = cVal
								end
							end
						end
					end
				end
				if type(sc.range) == "table" then
					args.colors = table.concat(sc.range, ",")
					args.colorScale = args.colors
				elseif type(sc.range) == "string" then
					args.colorScale = sc.range
				end
			end
		end
	end

	-- 3. 解析 axes 與 legends (含群組內部 axes、format 與標籤旋轉角度)
	local function inspectAxes(axesList, groupName)
		if type(axesList) ~= "table" then return end
		for _, ax in ipairs(axesList) do
			if ax.type == "x" then
				if ax.title then
					if groupName == "overview" then
						args._overviewXTitle = ax.title
					else
						args.xAxisTitle = args.xAxisTitle or ax.title
					end
				end
				if groupName ~= "overview" then
					if ax.grid then args.xGrid = true end
					if type(ax.format) == "string" and not args.xAxisFormat then
						args.xAxisFormat = ax.format
					end
					local lblProp = ax.properties and ax.properties.labels
					if lblProp and lblProp.angle and tonumber(lblProp.angle.value) and not args.xAxisAngle then
						args.xAxisAngle = tonumber(lblProp.angle.value)
					end
				end
			elseif ax.type == "y" then
				if ax.title then args.yAxisTitle = args.yAxisTitle or ax.title end
				if ax.grid then args.yGrid = true end
				if type(ax.format) == "string" and not args.yAxisFormat then
					args.yAxisFormat = ax.format
				end
			end
		end
	end
	inspectAxes(vega.axes, nil)

	if type(vega.legends) == "table" and #vega.legends > 0 then
		args.legend = vega.legends[1].title or "true"
	end

	-- 輔助函數:同時讀取 properties.enter 與 properties.update
	local function getProp(mk, key)
		local props = mk.properties
		if not props then return nil end
		if props.enter and props.enter[key] ~= nil then return props.enter[key] end
		if props.update and props.update[key] ~= nil then return props.update[key] end
		return nil
	end

	local hasOverviewGroup = false
	local rangedRectInfo = nil
	local ruleRangeInfo = nil
	local ganttInfo = nil
	local heatmapInfo = nil
	local mapSymbolMark = nil
	local hasPrimaryMark = false

	-- 4. 解析 marks 區塊
	local function inspectMarks(marksList, parentGroup)
		if type(marksList) ~= "table" then return end
		for _, mk in ipairs(marksList) do
			if mk.type == "group" then
				if mk.name == "overview" then hasOverviewGroup = true end
				inspectAxes(mk.axes, mk.name or parentGroup)
				if type(mk.legends) == "table" and #mk.legends > 0 and not args.legend then
					args.legend = mk.legends[1].title or "true"
				end
				if mk.from and type(mk.from.transform) == "table" then
					for _, tf in ipairs(mk.from.transform) do
						if tf.type == "facet" and tf.groupby then
							local gb = type(tf.groupby) == "table" and tf.groupby[1] or tf.groupby
							if type(gb) == "string" then args.groupField = gb end
						end
					end
				end
				inspectMarks(mk.marks, mk.name or parentGroup)
			elseif mk.type == "rule" then
				local rx = getProp(mk, "x")
				local ry = getProp(mk, "y")
				local ry2 = getProp(mk, "y2")
				local rx2 = getProp(mk, "x2")
				if ry and tonumber(ry.value) and rx2 then
					args.hAnnotationsLine = tostring(ry.value)
				elseif ry and type(ry.field) == "string" and ry2 and type(ry2.field) == "string" then
					-- 紀錄箱形圖上下鬚線 (Whiskers: y ~ y2)
					ruleRangeInfo = {
						xField = (rx and type(rx.field) == "string") and rx.field or args.xField,
						yMinField = ry.field,
						yMaxField = ry2.field
					}
				end
			elseif mk.type == "path" and args._isVegaMap then
				local upd = mk.properties and mk.properties.update
				if upd and type(upd.fill) == "table" then
					for _, rule in ipairs(upd.fill) do
						if rule.value and not rule.test then
							args.defaultValue = rule.value
						end
					end
				end
			elseif mk.type == "arc" then
				hasPrimaryMark = true
				args.type = "pie"
				if mk.from and type(mk.from.transform) == "table" then
					for _, tf in ipairs(mk.from.transform) do
						if tf.type == "pie" and type(tf.field) == "string" then
							args.yField = tf.field
						end
					end
				end
				local fillP = getProp(mk, "fill")
				local inRP = getProp(mk, "innerRadius")
				local outRP = getProp(mk, "outerRadius")
				if fillP and type(fillP.field) == "string" then args.xField = fillP.field end
				if inRP and tonumber(inRP.value) then args.innerRadius = tonumber(inRP.value) end
				if outRP and tonumber(outRP.value) then args.outerRadius = tonumber(outRP.value) end
			elseif mk.type == "line" or mk.type == "area" or mk.type == "rect" then
				hasPrimaryMark = true
				args.type = args.type or mk.type
				local xp = getProp(mk, "x")
				local x2p = getProp(mk, "x2")
				local yp = getProp(mk, "y")
				local y2p = getProp(mk, "y2")
				local fillP = getProp(mk, "fill")
				local strokeP = getProp(mk, "stroke")
				local swP = getProp(mk, "strokeWidth")
				local foP = getProp(mk, "fillOpacity")
				local interpP = getProp(mk, "interpolate")
				local wP = getProp(mk, "width")
				local hP = getProp(mk, "height")

				if xp and type(xp.field) == "string" then args.xField = xp.field end
				if yp and type(yp.field) == "string" then args.yField = yp.field end

				-- A. 偵測甘特圖 (Gantt Chart: rect 同時具備 x.field, x2.field 與 y.field)
				if mk.type == "rect" and xp and type(xp.field) == "string" and x2p and type(x2p.field) == "string" and yp and type(yp.field) == "string" then
					ganttInfo = {
						yField = yp.field,
						xStartField = xp.field,
						xEndField = x2p.field,
						colorField = (fillP and type(fillP.field) == "string" and fillP.field) or args.groupField
					}
				-- B. 偵測區間浮動長條圖或箱形圖盒體 (rect 具備 y.field 與 y2.field)
				elseif mk.type == "rect" and yp and type(yp.field) == "string" and y2p and type(y2p.field) == "string" then
					if parentGroup ~= "overview" or not rangedRectInfo then
						rangedRectInfo = {
							xField = (xp and type(xp.field) == "string") and xp.field or args.xField or "date",
							yLowField = yp.field,
							yHighField = y2p.field,
							colorField = (fillP and type(fillP.field) == "string" and fillP.field)
								or (strokeP and type(strokeP.field) == "string" and strokeP.field),
							fillOpacity = (foP and tonumber(foP.value)) or 0.4,
							strokeWidth = (swP and tonumber(swP.value)) or 1.5
						}
					end
				else
					if fillP and type(fillP.field) == "string" then
						colorDomainField = colorDomainField or fillP.field
					elseif strokeP and type(strokeP.field) == "string" then
						colorDomainField = colorDomainField or strokeP.field
					end
					if swP and tonumber(swP.value) then args.linewidth = tonumber(swP.value) end
					if interpP and type(interpP.value) == "string" then args.interpolate = interpP.value end

					local rectW = wP and tonumber(wP.value)
					local rectH = hP and tonumber(hP.value)
					-- C. 絕對定位像素方塊轉方形散佈圖 (如 QR Code)
					if mk.type == "rect" and rectW and rectH and xp and xp.field and yp and yp.field and not y2p then
						args.type = "line"
						args.linewidth = 0
						args.symbolsShape = "square"
						args._isScatterGrid = true
						args.hideAxes = true
						local side = math.max(rectW, rectH)
						args.showSymbols = string.format("%.4f", (side * 0.52) / 1.4)
						args.symbolsStroke = 0
					-- D. 偵測二維矩陣熱圖 (Heatmap: rect 具備 x.field, y.field 與 fill.field,且無固定像素寬高)
					elseif mk.type == "rect" and xp and type(xp.field) == "string" and yp and type(yp.field) == "string" and fillP and type(fillP.field) == "string" and not y2p and not x2p then
						heatmapInfo = {
							xField = xp.field,
							yField = yp.field,
							colorField = fillP.field
						}
					end
				end
			elseif mk.type == "symbol" then
				local xp = getProp(mk, "x")
				local yp = getProp(mk, "y")
				local szP = getProp(mk, "size")
				local fillP = getProp(mk, "fill")
				local shapeP = getProp(mk, "shape")
				if args._isVegaMap and mk.name ~= "handle" then
					mapSymbolMark = mk
				elseif not args._isVegaMap then
					if xp and type(xp.field) == "string" then args.xField = args.xField or xp.field end
					if yp and type(yp.field) == "string" then args.yField = args.yField or yp.field end
					if fillP and type(fillP.field) == "string" then
						colorDomainField = colorDomainField or fillP.field
					end
					if shapeP then
						if type(shapeP.value) == "string" then
							args.symbolsShape = shapeP.value
						elseif type(shapeP.scale) == "string" and type(vega.scales) == "table" then
							for _, sc in ipairs(vega.scales) do
								if sc.name == shapeP.scale and type(sc.range) == "table" then
									args.symbolsShape = sc.range
									break
								end
							end
						end
					end
					if szP and type(szP.field) == "string" then
						args.sizeField = szP.field
						args.showSymbols = args.showSymbols or 3.5
					else
						local sz = (szP and tonumber(szP.value)) or 30
						args.showSymbols = math.max(1.5, math.sqrt(sz / 8.5))
					end
					if fillP and fillP.value == "white" then
						args.symbolsFillWhite = true
						args.symbolsStroke = 1.8
					end
					if not hasPrimaryMark then
						args.type = "line"
						args.linewidth = 0
					end
				end
			elseif mk.type == "text" then
				local txtP = getProp(mk, "text")
				if mk.name == "yearLabel" and args._vegaSignals and args._vegaSignals.fromYear then
					args.dateLabel = tostring(args._vegaSignals.fromYear)
				elseif txtP and parentGroup ~= "tooltip" and mk.name ~= "tooltip" then
					if txtP.signal and args._vegaSignals and args._vegaSignals[txtP.signal] then
						if mk.name == "metricIndicator" or txtP.signal == "degrees" then
							args._unitLabel = tostring(args._vegaSignals[txtP.signal])
						else
							args.dateLabel = tostring(args._vegaSignals[txtP.signal])
						end
					elseif txtP.template and not args.title and not string.find(txtP.template, "tooltip", 1, true) and not string.find(txtP.template, "parent.", 1, true) then
						args.title = txtP.template
					elseif txtP.value and not args.title then
						args.title = tostring(txtP.value)
					end
				end
			end
		end
	end
	inspectMarks(vega.marks, nil)

	-- 5A. 若同時存在鬚線 (ruleRangeInfo) 與盒體 (rangedRectInfo),判定為箱形圖 (Box Plot)
	if ruleRangeInfo and rangedRectInfo and type(args._inlineRecords) == "table" and args._inlineRecords[1] ~= nil then
		args._boxPlotSpec = {
			records = args._inlineRecords,
			xField = rangedRectInfo.xField or ruleRangeInfo.xField,
			minField = ruleRangeInfo.yMinField,
			q1Field = rangedRectInfo.yLowField,
			q3Field = rangedRectInfo.yHighField,
			maxField = ruleRangeInfo.yMaxField,
			medianField = "median"
		}
		args.x = "0,1"
		args.y1 = "0,1"
		args._inlineRecords = nil
		return true, false
	end

	-- 5B. 若為甘特圖 (Gantt Chart)
	if ganttInfo and type(args._inlineRecords) == "table" and args._inlineRecords[1] ~= nil then
		args._ganttSpec = {
			records = args._inlineRecords,
			yField = ganttInfo.yField,
			xStartField = ganttInfo.xStartField,
			xEndField = ganttInfo.xEndField,
			colorField = ganttInfo.colorField
		}
		args.x = "0,1"
		args.y1 = "0,1"
		args._inlineRecords = nil
		return true, false
	end

	-- 5C. 若為二維矩陣熱圖 (Heatmap)
	if heatmapInfo and type(args._inlineRecords) == "table" and args._inlineRecords[1] ~= nil then
		args._heatmapSpec = {
			records = args._inlineRecords,
			xField = heatmapInfo.xField,
			yField = heatmapInfo.yField,
			colorField = heatmapInfo.colorField,
			colorDomain = colorRampDomain,
			colorRange = colorRampRange
		}
		args.x = "0,1"
		args.y1 = "0,1"
		args._inlineRecords = nil
		return true, false
	end

	-- 5D. 若為區間浮動長條圖 (如 Weather monthly history)
	if rangedRectInfo and type(args._inlineRecords) == "table" and args._inlineRecords[1] ~= nil then
		local sigs = args._vegaSignals or {}
		args._rangedRectSpec = {
			records = args._inlineRecords,
			xField = rangedRectInfo.xField,
			yLowField = rangedRectInfo.yLowField,
			yHighField = rangedRectInfo.yHighField,
			colorField = rangedRectInfo.colorField,
			colorDomain = colorRampDomain,
			colorRange = colorRampRange,
			fillOpacity = rangedRectInfo.fillOpacity,
			strokeWidth = rangedRectInfo.strokeWidth,
			showOverview = hasOverviewGroup and (sigs.showOverview ~= false),
			overviewHeight = tonumber(sigs.overviewHeight) or 40,
			gapHeight = tonumber(sigs.gapHeight) or 26,
			initYears = tonumber(sigs.init_years) or 7,
			minDate = sigs.min_date,
			maxDate = sigs.max_date,
			overviewXTitle = args._overviewXTitle or "Year selector",
			unitLabel = args._unitLabel
		}
		args.x = "0,1"
		args.y1 = "0,1"
		args._inlineRecords = nil
		return true, false
	end

	-- 5E. 若為 Vega 地圖,將對應資料集的 id/country -> v/calc/value 注入 args._mapValues
	if args._isVegaMap then
		local srcRecords = (mapLookupSource and datasets[mapLookupSource]) or datasets["yearData"] or datasets["highlights"] or args._inlineRecords
		local mapValues = {}
		if type(srcRecords) == "table" then
			local rIdx = 1
			while srcRecords[rIdx] ~= nil do
				local row = srcRecords[rIdx]
				local id = row.id or row.ISO3 or row.country or row.iso_a3
				local val = row.calc
				if val == nil then val = (row.v ~= nil) and row.v or row.value end
				if id ~= nil and val ~= nil and val == val then
					mapValues[tostring(id)] = val
				end
				rIdx = rIdx + 1
			end
		end
		args._mapValues = mapValues

		-- 掃描是否有經緯度點位資料 (Point / Symbol Map)
		local ptDataName = mapSymbolMark and mapSymbolMark.from and mapSymbolMark.from.data
		local ptRecords = (ptDataName and datasets[ptDataName]) or args._inlineRecords
		if type(ptRecords) == "table" and ptRecords[1] ~= nil then
			local mapPoints = {}
			local szP = mapSymbolMark and getProp(mapSymbolMark, "size")
			local fillP = mapSymbolMark and getProp(mapSymbolMark, "fill")
			local shapeP = mapSymbolMark and getProp(mapSymbolMark, "shape")
			local szField = szP and type(szP.field) == "string" and szP.field or nil
			local szConst = (szP and tonumber(szP.value)) and math.max(2.5, math.sqrt(tonumber(szP.value) / 6)) or 4.5
			local fillConst = (fillP and type(fillP.value) == "string") and fillP.value or "#e63946"
			local shapeField = shapeP and type(shapeP.field) == "string" and shapeP.field or nil
			local shapeConst = (shapeP and type(shapeP.value) == "string") and shapeP.value or nil

			local pIdx = 1
			while ptRecords[pIdx] ~= nil do
				local r = ptRecords[pIdx]
				local lon = tonumber(r.lon or r.lng or r.longitude or r[1])
				local lat = tonumber(r.lat or r.latitude or r[2])
				if lon and lat then
					local rawShp = (shapeField and r[shapeField]) or r.shape or shapeConst
					mapPoints[#mapPoints + 1] = {
						lon = lon,
						lat = lat,
						size = (szField and tonumber(r[szField])) or tonumber(r.size or r.mag) or szConst,
						color = r.color or fillConst,
						shape = rawShp and (shapes[rawShp] or tostring(rawShp)) or nil,
						label = r.name or r.label or r.title
					}
				end
				pIdx = pIdx + 1
			end
			if #mapPoints > 0 then args._mapPoints = mapPoints end
		end

		args._inlineRecords = nil
		return true, false
	end

	if colorDomainField then
		if args.type == "pie" then
			args.xField = args.xField or colorDomainField
			args.groupField = nil
		else
			args.groupField = args.groupField or colorDomainField
		end
	end

	return true, false
end

local function numericArray(csv)
	if not csv or csv == "" then return nil end
	local list = mw.text.split(tostring(csv), "%s*,%s*")
	local result = { n = #list }
	local isInteger = true
	local hasNumber = false

	for i = 1, #list do
		local item = mw.text.trim(list[i])
		if item == "" then
			result[i] = false
		else
			local num = tonumber(item)
			if not num then return nil end
			hasNumber = true
			result[i] = num
			if isInteger then
				local _, frac = math.modf(num)
				isInteger = (frac == 0.0)
			end
		end
	end
	if not hasNumber then return nil end
	return result, isInteger
end

local function stringArray(text)
	if not text or text == "" then return nil end
	local escaped = mw.ustring.gsub(tostring(text), "\\,", "<COMMA>")
	local list = mw.text.split(escaped, ",", true)
	for i = 1, #list do
		list[i] = mw.ustring.gsub(mw.text.trim(list[i]), "<COMMA>", ",")
	end
	list.n = #list
	return list
end

-- 解析 symbolsShape 參數:支援將完整 <svg>...</svg> 視為單一元素,避免內部 viewBox 或 path 逗號被切開
local function 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 = stringArray(masked)
	if not list then return nil end
	for i = 1, #list do
		local idx = tonumber(string.match(list[i], "^__SVG_SHAPE_(%d+)__$"))
		if idx and svgBlocks[idx] then
			list[i] = svgBlocks[idx]
		end
	end
	return list
end

local function parseBool(val, defaultVal)
	if val == nil or val == "" then return defaultVal end
	if val == false or val == 0 or val == "0" then return false end
	local s = string.lower(mw.text.trim(tostring(val)))
	if s == "false" or s == "n" or s == "no" or s == "0" then return false end
	return true
end

local function isDateArray(arr)
	if not arr or #arr == 0 then return false end
	local validCount = 0
	for i = 1, #arr do
		local s = arr[i]
		if s and s ~= "" then
			if string.match(s, "^%d%d%d%d[%-%/]%d%d?") then
				validCount = validCount + 1
			else
				return false
			end
		end
	end
	return validCount > 0
end

local function deserializeXData(serializedX, xType, xMin, xMax)
	if not serializedX or mw.text.trim(tostring(serializedX)) == "" then
		return { n = 0 }, xType or "number", tonumber(xMin), tonumber(xMax)
	end
	local x
	if not xType or xType == "integer" or xType == "number" then
		local isInteger
		x, isInteger = numericArray(serializedX)
		if x then
			xMin = tonumber(xMin)
			xMax = tonumber(xMax)
			if not xType then
				xType = isInteger and "integer" or "number"
			end
		elseif xType == "integer" or xType == "number" then
			x = stringArray(serializedX)
			xType = "string"
		end
	end
	if not x then
		x = stringArray(serializedX)
		if not xType then
			xType = isDateArray(x) and "date" or "string"
		end
	end
	return x, xType, xMin, xMax
end

local function deserializeYData(serializedYs, yType, yMin, yMax)
	local y = {}
	local areAllInteger = true
	local maxIndex = 0
	for k, _ in pairs(serializedYs) do
		if k > maxIndex then maxIndex = k end
	end

	for yNum = 1, maxIndex do
		local value = serializedYs[yNum]
		if value ~= nil then
			local yValues
			if not yType or yType == "integer" or yType == "number" then
				local isInteger
				yValues, isInteger = numericArray(value)
				if yValues then
					areAllInteger = areAllInteger and isInteger
				else
					return deserializeYData(serializedYs, "string", yMin, yMax)
				end
			end
			if not yValues then yValues = stringArray(value) end
			y[#y + 1] = yValues
		end
	end

	if not yType then
		yType = areAllInteger and "integer" or "number"
	end
	if yType == "integer" or yType == "number" then
		yMin = tonumber(yMin)
		yMax = tonumber(yMax)
	end
	return y, yType, yMin, yMax
end

local function buildColorAndAlphaPalette(colorsInput, chartType, xCount, yCount)
	local basePalette
	if colorsInput and #colorsInput > 0 then
		basePalette = {}
		for i = 1, #colorsInput do basePalette[i] = colorsInput[i] end
	else
		if (chartType == "pie" and xCount > 10) or yCount > 10 then
			basePalette = PALETTE_CAT20
		else
			basePalette = PALETTE_CAT10
		end
	end

	local colors, alphas = {}, {}
	for i = 1, #basePalette do
		local c = mw.text.trim(tostring(basePalette[i] or ""))
		local a, rgb = string.match(c, "^#(%x%x)(%x%x%x%x%x%x)$")
		if a and rgb then
			colors[i] = "#" .. rgb
			alphas[i] = tonumber(a, 16) / 255.0
		else
			colors[i] = c
			alphas[i] = 1.0
		end
	end
	return colors, alphas
end

local function parseShowValues(showValuesRaw)
	if not showValuesRaw or showValuesRaw == "0" or showValuesRaw == "false" or showValuesRaw == "no" then
		return nil
	end
	if type(showValuesRaw) == "table" then return showValuesRaw end
	local result = {}
	local str = mw.text.trim(tostring(showValuesRaw))
	if str == "" or str == "true" or str == "yes" or str == "1" then
		return result
	end
	local keyValues = mw.text.split(str, "%s*,%s*")
	for _, kv in ipairs(keyValues) do
		local k, v = mw.ustring.match(kv, "^%s*(.-)%s*:%s*(.-)%s*$")
		if k and k ~= "" then
			result[string.lower(k)] = v
		end
	end
	return result
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] = k .. "=" .. tostring(v)
			elseif v ~= "" then
				merged[k] = 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

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

	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 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 then
		showLegend = true
		local trimmedLeg = mw.text.trim(tostring(legendRaw))
		if trimmedLeg ~= "" and string.lower(trimmedLeg) ~= "true" and string.lower(trimmedLeg) ~= "yes" and string.lower(trimmedLeg) ~= "1" then
			legendTitle = trimmedLeg
		end
	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 x
	x, xType, xMin, xMax = deserializeXData(args.x, xType, xMin, xMax)

	local yValues = {}
	local rawTitles = {}
	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 ("y" .. yNum)
			if yNum > maxYNum then maxYNum = yNum end
		end
	end

	local compactYValues = {}
	local seriesTitles = {}
	for idx = 1, maxYNum do
		if yValues[idx] ~= nil then
			compactYValues[#compactYValues + 1] = yValues[idx]
			seriesTitles[#seriesTitles + 1] = rawTitles[idx]
		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 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 == "bubble" or chartType == "scatter" then
		chartType = "line"
		linewidth = 0
		if not showSymbolsRaw then showSymbolsRaw = "3.5" end
	elseif chartType == "radar" or chartType == "spider" then
		chartType = "radar"
		if showSymbolsRaw == nil then showSymbolsRaw = "2.5" end
	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)

	-- 若使用者指定了 symbolsShape 但未顯式關閉 showSymbols,自動啟用符號繪製
	if symbolsShapeRaw and showSymbolsRaw == nil then
		showSymbolsRaw = "3.5"
	end

	local symbols = nil
	if showSymbolsRaw and parseBool(showSymbolsRaw, true) ~= false and chartType ~= "rect" 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,
		stacked = stacked,
		interpolate = interpolate,
		colors = colors,
		alphas = alphas,
		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),
		persistentGrey = persistentGrey,
		x = x,
		x2 = x2,
		y = y,
		sizes = hasSizes and sizes or nil,
		seriesTitles = seriesTitles
	}
end

function p.chart(frame)
	local args = extractArgs(frame)
	local rawJsonInput = args.json or args.vega or args.content or args[1]

	if string.match(rawJsonInput or '', "<[sS][vV][gG]%s*([^>]*)>") then
		return SVGRenderer.RenderSVG(frame)
	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)
	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