跳转到内容

模組:Graph/MapRenderer

维基百科,自由的百科全书
-- Module:Graph/MapRenderer
local p = {}
local TrackingCategory = require('Module:TrackingCategory')

local format, floor, ceil, abs, max, min = string.format, math.floor, math.ceil, math.abs, math.max, math.min
local sin, cos, tan, log, sqrt, pi = math.sin, math.cos, math.tan, math.log, math.sqrt, math.pi
local DEG2RAD = pi / 180

local baseMapDirectory = "Module:Graph/"

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

local CSS_COLORS = {
	white = {255, 255, 255}, black = {0, 0, 0}, silver = {192, 192, 192},
	gainsboro = {220, 220, 220}, gray = {128, 128, 128}, grey = {128, 128, 128},
	lightgrey = {211, 211, 211}, darkgray = {169, 169, 169}, darkgrey = {169, 169, 169},
	red = {255, 0, 0}, darkred = {139, 0, 0}, green = {0, 128, 0},
	darkgreen = {0, 100, 0}, lightgreen = {144, 238, 144}, lime = {0, 255, 0},
	blue = {0, 0, 255}, darkblue = {0, 0, 139}, lightblue = {173, 216, 230},
	dodgerblue = {30, 144, 255}, turquoise = {64, 224, 208}, blueviolet = {138, 43, 226},
	navy = {0, 0, 128}, steelblue = {70, 130, 180}, skyblue = {135, 206, 235},
	yellow = {255, 255, 0}, gold = {255, 215, 0}, orange = {255, 165, 0},
	darkorange = {255, 140, 0}, purple = {128, 0, 128}, violet = {238, 130, 238},
	pink = {255, 192, 203}, brown = {165, 42, 42}, cyan = {0, 255, 255},
	teal = {0, 128, 128}, magenta = {255, 0, 255}, olive = {128, 128, 0},
	maroon = {128, 0, 0}, coral = {255, 127, 80}, tomato = {255, 99, 71}, khaki = {240, 230, 140}
}

local function esc(s)
	local str = tostring(s or "")
	str = string.gsub(str, "\127'\"`UNIQ", "__MW_SM_PRE__UNIQ")
	str = string.gsub(str, "QINU`\"'\127", "QINU__MW_SM_SUF__")
	return mw.text.encode(str)
end

local function numericArray(csv)
	if not csv or csv == "" then return nil end
	local list = mw.text.split(tostring(csv), "%s*,%s*")
	local res = {}
	for i = 1, #list do
		local n = tonumber(list[i])
		if not n then return nil end
		res[i] = n
	end
	return res
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
	return list
end

local function parseRGB(cStr)
	if not cStr then return {192, 192, 192} end
	cStr = string.lower(mw.text.trim(tostring(cStr)))
	if CSS_COLORS[cStr] then return CSS_COLORS[cStr] end
	local r, g, b = string.match(cStr, "^#(%x%x)(%x%x)(%x%x)$")
	if r then return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) } end
	local _, ar, ag, ab = string.match(cStr, "^#(%x%x)(%x%x)(%x%x)(%x%x)$")
	if ar then return { tonumber(ar, 16), tonumber(ag, 16), tonumber(ab, 16) } end
	local sr, sg, sb = string.match(cStr, "^#(%x)(%x)(%x)$")
	if sr then return { tonumber(sr .. sr, 16), tonumber(sg .. sg, 16), tonumber(sb .. sb, 16) } end
	return {192, 192, 192}
end

local function rgbToHex(r, g, b)
	return format("#%02x%02x%02x",
		max(0, min(255, floor(r + 0.5))),
		max(0, min(255, floor(g + 0.5))),
		max(0, min(255, floor(b + 0.5)))
	)
end

local function buildColorMapper(scaleType, dMin, dMax, colorScaleInput)
	local palette
	local csLower = colorScaleInput and string.lower(mw.text.trim(tostring(colorScaleInput))) or "category10"
	if csLower == "category10" then
		palette = PALETTE_CAT10
	elseif csLower == "category20" then
		palette = PALETTE_CAT20
	else
		palette = stringArray(colorScaleInput)
		if not palette or #palette == 0 then palette = PALETTE_CAT10 end
	end

	local rgbStops = {}
	for i = 1, #palette do
		rgbStops[i] = parseRGB(palette[i])
	end
	if #rgbStops == 1 then
		rgbStops[2] = rgbStops[1]
	end

	local sType = string.lower(mw.text.trim(tostring(scaleType or "linear")))
	local powExp = tonumber(string.match(sType, "pow%s+([%d%.]+)"))
	if powExp then sType = "pow" else powExp = 2 end

	if dMin == dMax then dMax = dMin + 1 end

	local function normalize(v)
		v = max(dMin, min(dMax, v))
		if sType == "log" then
			local lMin = log(max(1e-9, dMin))
			local lMax = log(max(1e-8, dMax))
			return (log(max(1e-9, v)) - lMin) / (lMax - lMin)
		elseif sType == "sqrt" then
			local sMin = sqrt(max(0, dMin))
			local sMax = sqrt(max(1e-9, dMax))
			return (sqrt(max(0, v)) - sMin) / (sMax - sMin)
		elseif sType == "pow" then
			local pMin = (dMin >= 0 and 1 or -1) * (abs(dMin) ^ powExp)
			local pMax = (dMax >= 0 and 1 or -1) * (abs(dMax) ^ powExp)
			local pv = (v >= 0 and 1 or -1) * (abs(v) ^ powExp)
			return (pv - pMin) / (pMax - pMin)
		end
		return (v - dMin) / (dMax - dMin)
	end

	local nStops = #rgbStops
	local isQuantize = (sType == "quantize" or sType == "quantile")

	local function mapValueToColor(val)
		if val == nil then return nil end
		local t = max(0, min(1, normalize(val)))
		if isQuantize then
			local idx = min(nStops, floor(t * nStops) + 1)
			local c = rgbStops[idx]
			return rgbToHex(c[1], c[2], c[3])
		end
		local scaled = t * (nStops - 1)
		local idx = min(nStops - 1, floor(scaled) + 1)
		local frac = scaled - (idx - 1)
		local c1, c2 = rgbStops[idx], rgbStops[idx + 1]
		return rgbToHex(
			c1[1] + (c2[1] - c1[1]) * frac,
			c1[2] + (c2[2] - c1[2]) * frac,
			c1[3] + (c2[3] - c1[3]) * frac
		)
	end

	return mapValueToColor, rgbStops
end

local function loadTopoJsonTable(basemap)
	local pageName = mw.text.trim(tostring(basemap or "Template:Graph:Map/Inner/Worldmap2c-json"))
	local isMapProtocol = false
	if string.sub(pageName, 1, 9) == "wikiraw:/" then
		pageName = string.gsub(pageName, "^wikiraw:///?", "")
		pageName = mw.uri.decode(pageName, "PATH")
	elseif string.match(pageName, "^map:/") or string.match(pageName, "^tabular:/") then
		pageName = string.gsub(pageName, "^map:///?", "")
		pageName = string.gsub(pageName, "^tabular:///?", "")
		pageName = mw.uri.decode(pageName, "PATH")
		isMapProtocol = true
	end
	pageName = mw.text.trim(pageName)

	-- 1. 優先透過 mw.ext.data.get 讀取維基共享資源 Data: 命名空間中的 .map / .tab 底圖
	if isMapProtocol or string.match(pageName, "%.[mM][aA][pP]$") 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, mapObj = pcall(mw.ext.data.get, spaceDataPage)
			if not ok or type(mapObj) ~= "table" then
				ok, mapObj = pcall(mw.ext.data.get, cleanDataPage)
			end
			if ok and type(mapObj) == "table" then
				return mapObj, nil
			end
		end
		pageName = "Data:" .. cleanDataPage
	elseif not string.find(pageName, ":", 1, true) then
		local t1 = mw.title.new("Template:Graph:Map/Inner/" .. pageName)
		if t1 and t1.exists then
			pageName = t1.prefixedText
		else
			pageName = baseMapDirectory .. pageName
		end
	end

	-- 2. 備援讀取本地頁面
	local titleObj = mw.title.new(pageName)
	local content = titleObj and titleObj:getContent()
	if not content then
		return nil, "找不到底圖頁面: " .. pageName
	end

	content = string.gsub(content, "<noinclude>.-</noinclude>", "")
	content = string.gsub(content, "</?includeonly>", "")
	local ok, data = pcall(mw.text.jsonDecode, mw.text.trim(content), mw.text.JSON_TRY_FIXING)
	if not ok or type(data) ~= "table" then
		return nil, "底圖 JSON 解析失敗: " .. pageName
	end
	return data, nil
end

local function createProjector(projection, scale, center)
	local proj = string.lower(mw.text.trim(tostring(projection or "equirectangular")))
	scale = tonumber(scale) or 100
	local cLon = (center and center[1]) or 0
	local cLat = (center and center[2]) or 0

	if proj == "mercator" then
		local y0 = log(tan(pi / 4 + (max(-85, min(85, cLat)) * DEG2RAD) / 2)) * scale
		return function(lon, lat)
			local x = (lon - cLon) * DEG2RAD * scale
			local clampedLat = max(-85, min(85, lat))
			local y = -(log(tan(pi / 4 + (clampedLat * DEG2RAD) / 2)) * scale - y0)
			return x, y
		end
	elseif proj == "orthographic" then
		local phi0 = cLat * DEG2RAD
		local sinPhi0, cosPhi0 = sin(phi0), cos(phi0)
		return function(lon, lat)
			local phi = lat * DEG2RAD
			local dLam = (lon - cLon) * DEG2RAD
			local cosPhi = cos(phi)
			local x = scale * cosPhi * sin(dLam)
			local y = -scale * (cosPhi0 * sin(phi) - sinPhi0 * cosPhi * cos(dLam))
			return x, y
		end
	end

	return function(lon, lat)
		local x = (lon - cLon) * DEG2RAD * scale
		local y = -(lat - cLat) * DEG2RAD * scale
		return x, y
	end
end

local function decodeTopoJsonToPaths(topo, featureName, projection, scale, center, minSqDist)
	local tf = topo.transform
	local sx = (tf and tf.scale and tf.scale[1]) or 1
	local sy = (tf and tf.scale and tf.scale[2]) or 1
	local tx = (tf and tf.translate and tf.translate[1]) or 0
	local ty = (tf and tf.translate and tf.translate[2]) or 0
	local isDelta = (tf ~= nil)

	local project = createProjector(projection, scale, center)
	local rawArcs = topo.arcs or {}
	local projArcs = {}
	local minX, minY, maxX, maxY = math.huge, math.huge, -math.huge, -math.huge
	local sqTol = minSqDist or 0.35

	for k = 1, #rawArcs do
		local arc = rawArcs[k]
		local nPts = #arc
		local pts = {}
		local qx, qy = 0, 0
		local lastPx, lastPy = nil, nil

		for i = 1, nPts do
			local pt = arc[i]
			local lon, lat
			if isDelta then
				qx = qx + pt[1]
				qy = qy + pt[2]
				lon = qx * sx + tx
				lat = qy * sy + ty
			else
				lon, lat = pt[1], pt[2]
			end

			local px, py = project(lon, lat)
			if px < minX then minX = px end
			if px > maxX then maxX = px end
			if py < minY then minY = py end
			if py > maxY then maxY = py end

			-- 確保小島弧線 (nPts <= 5) 不被過濾掉頂點,避免閉合環少於 3 點而消失
			if nPts <= 5 or i == 1 or i == nPts or ((px - lastPx) * (px - lastPx) + (py - lastPy) * (py - lastPy) >= sqTol) then
				pts[#pts + 1] = format("%.1f,%.1f", px, py)
				lastPx, lastPy = px, py
			end
		end
		projArcs[k] = pts
	end

	if minX == math.huge then minX, minY, maxX, maxY = -314, -157, 314, 157 end

	local function buildRingPath(ring)
		local rbuf = {}
		for a = 1, #ring do
			local arcIdx = ring[a]
			if arcIdx >= 0 then
				local pts = projArcs[arcIdx + 1]
				if pts then
					local startIdx = (a == 1) and 1 or 2
					for i = startIdx, #pts do
						rbuf[#rbuf + 1] = pts[i]
					end
				end
			else
				local pts = projArcs[-arcIdx]
				if pts then
					local startIdx = (a == 1) and #pts or (#pts - 1)
					for i = startIdx, 1, -1 do
						rbuf[#rbuf + 1] = pts[i]
					end
				end
			end
		end
		if #rbuf < 3 then return nil end
		return "M" .. table.concat(rbuf, "L") .. "Z"
	end

	local objects = topo.objects or {}
	local targetObj = objects[featureName]
	if not targetObj then
		for _, v in pairs(objects) do
			targetObj = v
			break
		end
	end

	local geometries = (targetObj and targetObj.geometries) or {}
	local featuresOut = {}

	for g = 1, #geometries do
		local geom = geometries[g]
		local gType = geom.type
		local gArcs = geom.arcs
		local pathParts = {}

		if gType == "Polygon" and gArcs then
			for r = 1, #gArcs do
				local ringStr = buildRingPath(gArcs[r])
				if ringStr then pathParts[#pathParts + 1] = ringStr end
			end
		elseif gType == "MultiPolygon" and gArcs then
			for pIdx = 1, #gArcs do
				local poly = gArcs[pIdx]
				for r = 1, #poly do
					local ringStr = buildRingPath(poly[r])
					if ringStr then pathParts[#pathParts + 1] = ringStr end
				end
			end
		end

		if #pathParts > 0 then
			featuresOut[#featuresOut + 1] = {
				id = geom.id and tostring(geom.id) or "",
				d = table.concat(pathParts, "")
			}
		end
	end

	return {
		minX = minX,
		minY = minY,
		maxX = maxX,
		maxY = maxY,
		features = featuresOut
	}
end

-- 將維基共享資源 Data:*.map 的 GeoJSON (FeatureCollection) 投影並編碼為 SVG Path 結構
-- (同時相容 mw.ext.data.get 的 0-based 陣列與 mw.text.jsonDecode 的 1-based 陣列)
local function decodeGeoJsonToPaths(geoObj, projection, scale, center, minSqDist)
	local fc = (geoObj.type == "FeatureCollection") and geoObj
		or (type(geoObj.data) == "table" and geoObj.data.type == "FeatureCollection" and geoObj.data)
		or geoObj
	local rawFeatures = (fc and fc.features) or (type(geoObj.data) == "table" and geoObj.data)
	if type(rawFeatures) ~= "table" then
		return { minX = -314, minY = -157, maxX = 314, maxY = 157, features = {} }
	end

	local project = createProjector(projection, scale, center)
	local minX, minY, maxX, maxY = math.huge, math.huge, -math.huge, -math.huge
	local sqTol = minSqDist or 0.35
	local featuresOut = {}

	local function buildGeoRingPath(ring)
		if type(ring) ~= "table" then return nil end
		local sIdx = (ring[0] ~= nil) and 0 or 1
		if ring[sIdx] == nil then return nil end
		local eIdx = sIdx
		while ring[eIdx + 1] ~= nil do eIdx = eIdx + 1 end
		local nPts = eIdx - sIdx + 1
		local rbuf = {}
		local lastPx, lastPy = nil, nil

		for i = sIdx, eIdx do
			local pt = ring[i]
			if type(pt) == "table" then
				local lon, lat
				if pt[0] ~= nil then
					lon, lat = tonumber(pt[0]), tonumber(pt[1])
				else
					lon, lat = tonumber(pt[1]), tonumber(pt[2])
				end
				if lon and lat then
					local px, py = project(lon, lat)
					if px < minX then minX = px end
					if px > maxX then maxX = px end
					if py < minY then minY = py end
					if py > maxY then maxY = py end

					if nPts <= 5 or i == sIdx or i == eIdx or not lastPx or ((px - lastPx) * (px - lastPx) + (py - lastPy) * (py - lastPy) >= sqTol) then
						rbuf[#rbuf + 1] = format("%.1f,%.1f", px, py)
						lastPx, lastPy = px, py
					end
				end
			end
		end
		if #rbuf < 3 then return nil end
		return "M" .. table.concat(rbuf, "L") .. "Z"
	end

	local fIdx = (rawFeatures[0] ~= nil) and 0 or 1
	while rawFeatures[fIdx] ~= nil do
		local feat = rawFeatures[fIdx]
		local geom = feat and feat.geometry
		local props = (feat and feat.properties) or {}
		local iso3 = props.iso_a3 or props.ISO_A3
		if not iso3 or iso3 == "-99" or iso3 == "" then
			iso3 = props.adm0_a3 or props.ADM0_A3
		end
		local id = feat.id or iso3 or props.id or props.name or tostring(fIdx)
		local pathParts = {}

		if type(geom) == "table" and type(geom.coordinates) == "table" then
			local gType = geom.type
			local coords = geom.coordinates
			if gType == "Polygon" then
				local r = (coords[0] ~= nil) and 0 or 1
				while coords[r] ~= nil do
					local ringStr = buildGeoRingPath(coords[r])
					if ringStr then pathParts[#pathParts + 1] = ringStr end
					r = r + 1
				end
			elseif gType == "MultiPolygon" then
				local pIdx = (coords[0] ~= nil) and 0 or 1
				while coords[pIdx] ~= nil do
					local poly = coords[pIdx]
					if type(poly) == "table" then
						local r = (poly[0] ~= nil) and 0 or 1
						while poly[r] ~= nil do
							local ringStr = buildGeoRingPath(poly[r])
							if ringStr then pathParts[#pathParts + 1] = ringStr end
							r = r + 1
						end
					end
					pIdx = pIdx + 1
				end
			end
		end

		if #pathParts > 0 then
			featuresOut[#featuresOut + 1] = {
				id = tostring(id),
				d = table.concat(pathParts, "")
			}
		end
		fIdx = fIdx + 1
	end

	if minX == math.huge then minX, minY, maxX, maxY = -314, -157, 314, 157 end
	return {
		minX = minX,
		minY = minY,
		maxX = maxX,
		maxY = maxY,
		features = featuresOut
	}
end

-- 修復跨越 ±180° 國際換日線的多邊形路徑 (消除俄羅斯楚科奇半島、弗蘭格爾島、斐濟橫跨地圖的破圖橫線)
local function fixAntimeridianPath(d, spanX, midX)
	if not d or d == "" or not spanX or spanX <= 0 then return d end
	local jumpThreshold = spanX * 0.42
	local outSubpaths = {}

	for subPath in string.gmatch(d, "M[^M]+") do
		local pts = {}
		local hasJump = false
		local prevX = nil

		for xStr, yStr in string.gmatch(subPath, "([%+%-]?%d+%.?%d*),([%+%-]?%d+%.?%d*)") do
			local x, y = tonumber(xStr), tonumber(yStr)
			if x and y then
				if prevX and math.abs(x - prevX) > jumpThreshold then
					hasJump = true
				end
				prevX = x
				pts[#pts + 1] = { x, y, xStr, yStr }
			end
		end

		if #pts > 1 and math.abs(pts[1][1] - pts[#pts][1]) > jumpThreshold then
			hasJump = true
		end

		if not hasJump or #pts < 3 then
			outSubpaths[#outSubpaths + 1] = mw.text.trim(subPath)
		else
			-- 將跨日界線的環依跳躍點切分為連續線段
			local segs = { { pts[1] } }
			for i = 2, #pts do
				if math.abs(pts[i][1] - pts[i - 1][1]) > jumpThreshold then
					segs[#segs + 1] = { pts[i] }
				else
					local cur = segs[#segs]
					cur[#cur + 1] = pts[i]
				end
			end

			-- 若首尾線段位於同一側半球,將其合併為連續段落
			if #segs > 1 then
				local firstSeg = segs[1]
				local lastSeg = segs[#segs]
				if math.abs(lastSeg[#lastSeg][1] - firstSeg[1][1]) <= jumpThreshold then
					for i = 1, #firstSeg do
						lastSeg[#lastSeg + 1] = firstSeg[i]
					end
					table.remove(segs, 1)
				end
			end

			-- 統計東西半球頂點數,將主體側合併閉合、突出側個別沿日界線閉合
			local eastCount, westCount = 0, 0
			for _, seg in ipairs(segs) do
				if seg[1][1] >= midX then
					eastCount = eastCount + #seg
				else
					westCount = westCount + #seg
				end
			end
			local mainIsEast = (eastCount >= westCount)
			local mainPts = {}

			for _, seg in ipairs(segs) do
				local isEast = (seg[1][1] >= midX)
				if isEast == mainIsEast then
					for i = 1, #seg do mainPts[#mainPts + 1] = seg[i] end
				else
					if #seg >= 3 then
						local b = { "M" .. seg[1][3] .. "," .. seg[1][4] }
						for i = 2, #seg do
							b[#b + 1] = "L" .. seg[i][3] .. "," .. seg[i][4]
						end
						b[#b + 1] = "Z"
						outSubpaths[#outSubpaths + 1] = table.concat(b, " ")
					end
				end
			end

			if #mainPts >= 3 then
				local b = { "M" .. mainPts[1][3] .. "," .. mainPts[1][4] }
				for i = 2, #mainPts do
					b[#b + 1] = "L" .. mainPts[i][3] .. "," .. mainPts[i][4]
				end
				b[#b + 1] = "Z"
				outSubpaths[#outSubpaths + 1] = table.concat(b, " ")
			end
		end
	end

	return table.concat(outSubpaths, " ")
end

-- 計算國家多邊形路徑中「面積最大之本土陸塊」的視覺中心點,供放置地圖懸停感應區
local function findMainlandCenter(d)
	if not d or d == "" then return nil, nil end
	local bestArea = -1
	local bestCx, bestCy = nil, nil
	for subPath in string.gmatch(d, "M[^M]+") do
		local x0, y0, x1, y1 = math.huge, math.huge, -math.huge, -math.huge
		local cnt = 0
		for xStr, yStr in string.gmatch(subPath, "([%+%-]?%d+%.?%d*),([%+%-]?%d+%.?%d*)") do
			local x, y = tonumber(xStr), tonumber(yStr)
			if x and y then
				if x < x0 then x0 = x end
				if x > x1 then x1 = x end
				if y < y0 then y0 = y end
				if y > y1 then y1 = y end
				cnt = cnt + 1
			end
		end
		if cnt >= 3 then
			local area = (x1 - x0) * (y1 - y0)
			if area > bestArea then
				bestArea = area
				bestCx = (x0 + x1) / 2
				bestCy = (y0 + y1) / 2
			end
		end
	end
	return bestCx, bestCy
end

-- 解析地圖點標記形狀清單:保護 <svg>...</svg> 內部逗號不被切碎
local function parseMapShapeList(text)
	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(m)
		svgBlocks[#svgBlocks + 1] = m
		return "__MAP_SVG_" .. #svgBlocks .. "__"
	end)
	local parts = mw.text.split(masked, "%s*,%s*")
	for i = 1, #parts do
		local idx = tonumber(string.match(parts[i], "^__MAP_SVG_(%d+)__$"))
		if idx and svgBlocks[idx] then
			parts[i] = svgBlocks[idx]
		end
	end
	return parts
end

function p.renderMap(args)
	args = args or {}
	local basemap = args.basemap or "Template:Graph:Map/Inner/Worldmap2c-json"
	local scale = tonumber(args.scale) or 100
	local projection = string.lower(mw.text.trim(tostring(args.projection or "equirectangular")))
	local defaultValue = args.defaultValue or args.defaultvalue
	local scaleType = args.scaleType or args.scaletype or "linear"
	local domainMin = tonumber(args.domainMin or args.domainmin)
	local domainMax = tonumber(args.domainMax or args.domainmax)
	local colorScale = args.colorScale or args.colorscale or "category10"
	local showLegend = args.legend and args.legend ~= "" and args.legend ~= "0" and args.legend ~= "no" and args.legend ~= "false"
	local featureName = args.feature or "countries"
	local center = numericArray(args.center)
	TrackingCategory.append('有SVG圖表的頁面')

	local valueMap = {}
	local isNumbers = nil
	local calcMin, calcMax = math.huge, -math.huge

	for name, value in pairs(args) do
		if type(name) == "string" and mw.ustring.find(name, "^[^%l]+$") and value and mw.text.trim(tostring(value)) ~= "" then
			local trimmed = mw.text.trim(tostring(value))
			local numVal = tonumber(trimmed)
			if isNumbers == nil then
				isNumbers = (numVal ~= nil)
			end
			if isNumbers and numVal then
				valueMap[name] = numVal
				if numVal < calcMin then calcMin = numVal end
				if numVal > calcMax then calcMax = numVal end
			else
				valueMap[name] = trimmed
			end
		end
	end

	if type(args._mapValues) == "table" then
		for k, v in pairs(args._mapValues) do
			local numVal = tonumber(v)
			if isNumbers == nil then isNumbers = (numVal ~= nil) end
			if isNumbers and numVal then
				valueMap[k] = numVal
				if numVal < calcMin then calcMin = numVal end
				if numVal > calcMax then calcMax = numVal end
			else
				valueMap[k] = tostring(v)
			end
		end
	end

	if not defaultValue then
		defaultValue = isNumbers and "#d9d9d9" or "silver"
	elseif isNumbers and tonumber(defaultValue) then
		local defNum = tonumber(defaultValue)
		if defNum < calcMin then calcMin = defNum end
		if defNum > calcMax then calcMax = defNum end
	end

	local colorMapper, rgbStops
	local dMin = domainMin or (calcMin < math.huge and calcMin or 0)
	local dMax = domainMax or (calcMax > -math.huge and calcMax or 100)
	if isNumbers then
		colorMapper, rgbStops = buildColorMapper(scaleType, dMin, dMax, colorScale)
	end

	local isDefaultWorldMap = (string.find(basemap, "Worldmap2c%-json") ~= nil)
		and (projection == "equirectangular")
		and (not center or (center[1] == 0 and center[2] == 0))

	local mapData = nil
	local scaleFactor = 1.0

	if isDefaultWorldMap then
		local ok, staticData = pcall(mw.loadData, "Module:Graph/WorldMapData")
		if ok and type(staticData) == "table" and staticData.features and staticData.features[1] then
			mapData = staticData
			scaleFactor = scale / (staticData.baseScale or 100)
		end
	end

	if not mapData then
		local topo, err = loadTopoJsonTable(basemap)
		if not topo then
			return '<div class="error">' .. esc(err) .. '</div>'
		end
		local tol = 0.35 * ((scale / 100) ^ 2)
		if topo.type == "FeatureCollection" or (type(topo.data) == "table" and topo.data.type == "FeatureCollection") or type(topo.features) == "table" then
			mapData = decodeGeoJsonToPaths(topo, projection, scale, center, tol)
		else
			mapData = decodeTopoJsonToPaths(topo, featureName, projection, scale, center, tol)
		end
		scaleFactor = 1.0
	end

	-- 取得原始未縮放的座標跨距 (供 fixAntimeridianPath 判斷跨日線使用)
	local rawDataW = mapData.maxX - mapData.minX
	local rawMidX = (mapData.minX + mapData.maxX) / 2

	local minX = mapData.minX * scaleFactor
	local minY = mapData.minY * scaleFactor
	local maxX = mapData.maxX * scaleFactor
	local maxY = mapData.maxY * scaleFactor
	local mapW = math.max(50, maxX - minX)
	local mapH = math.max(50, maxY - minY)

	local pad = 4
	local legH = (showLegend and isNumbers) and 42 or 0
	-- 若有標題或日期,預留 34px 的頂部空間避免文字壓在北極圈與俄羅斯上方
	local headerH = (args.title or args.dateLabel) and 34 or 0
	local topPad = pad + headerH

	local totalW = math.ceil(mapW + pad * 2)
	local totalH = math.ceil(mapH + topPad + pad + legH)

	local buf = {}
	local gradId = "map-grad-1"

	if showLegend and isNumbers and rgbStops then
		local gbuf = { string.format('<defs><linearGradient id="%s" x1="0%%" y1="0%%" x2="100%%" y2="0%%">', gradId) }
		local nStops = #rgbStops
		for i = 1, nStops do
			local pct = ((i - 1) / math.max(1, nStops - 1)) * 100
			local c = rgbStops[i]
			gbuf[#gbuf + 1] = string.format('<stop offset="%.1f%%" stop-color="%s"/>', pct, rgbToHex(c[1], c[2], c[3]))
		end
		gbuf[#gbuf + 1] = '</linearGradient></defs>'
		buf[#buf + 1] = table.concat(gbuf)
	end

	if args.background then
		buf[#buf + 1] = string.format('<rect x="0" y="0" width="%d" height="%d" fill="%s"/>', totalW, totalH, esc(args.background))
	end

	local tx = pad - minX
	local ty = topPad - minY -- 套用新的頂部留白
	if math.abs(scaleFactor - 1.0) > 1e-4 then
		buf[#buf + 1] = string.format('<g transform="translate(%.2f,%.2f) scale(%.4f)" stroke="#ffffff" stroke-width="%.2f" stroke-linejoin="round">',
			tx, ty, scaleFactor, 0.5 / scaleFactor)
	else
		buf[#buf + 1] = string.format('<g transform="translate(%.2f,%.2f)" stroke="#ffffff" stroke-width="0.5" stroke-linejoin="round">', tx, ty)
	end

	local Utils = require('Module:Graph/RenderUtils')
	local isInteractive = (args.interactive == nil) or (tostring(args.interactive) ~= "no" and tostring(args.interactive) ~= "false" and tostring(args.interactive) ~= "0")
	local tipBuf = {}

	local features = mapData.features
	local idx = 1
	while features[idx] do
		local feat = features[idx]
		local id = feat.id
		local rawVal = (id and id ~= "") and valueMap[id] or nil
		local fillColor
		if rawVal ~= nil then
			if isNumbers then
				fillColor = colorMapper(tonumber(rawVal))
			else
				fillColor = tostring(rawVal)
			end
		else
			if isNumbers and tonumber(defaultValue) then
				fillColor = colorMapper(tonumber(defaultValue))
			else
				fillColor = tostring(defaultValue or "silver")
			end
		end
		-- 清洗並修復跨日界線
		local cleanD = fixAntimeridianPath(feat.d, rawDataW, rawMidX)
		buf[#buf + 1] = string.format('<path fill="%s" d="%s"/>', esc(fillColor), cleanD)

		-- 若該地區有設定數值且啟用互動,於其本土陸塊中心建立懸停提示框
		if isInteractive and rawVal ~= nil and #tipBuf < 240 then
			local mcx, mcy = findMainlandCenter(cleanD)
			if mcx and mcy then
				local px = tx + mcx * scaleFactor
				local py = ty + mcy * scaleFactor
				local vDisp = isNumbers and Utils.formatNumber(rawVal, nil, false) or tostring(rawVal)
				local tipHtml = string.format('<b>%s</b>: %s', esc(id), esc(vDisp))
				tipBuf[#tipBuf + 1] = Utils.buildPointHit(px, py, totalW, totalH, tipHtml, fillColor, 14)
			end
		end
		idx = idx + 1
	end
	buf[#buf + 1] = '</g>'

	-- 繪製點標記地圖圖層 (Point / Symbol Map:支援 Vega _mapPoints、模板 lat/lon 與 pointShape 自訂 <svg> 圖示)
	local mapPoints = args._mapPoints
	local rawShapeArg = args.pointShape or args.pointshape or args.pointShapes or args.pointshapes or args.symbolsShape or args.symbolsshape
	local shapeList = parseMapShapeList(rawShapeArg)

	if not mapPoints and args.lat and args.lon then
		local latList = numericArray(args.lat)
		local lonList = numericArray(args.lon)
		local szList = numericArray(args.pointSize or args.pointsize)
		local defSz = tonumber(args.pointSize or args.pointsize) or 4.5
		local defCol = args.pointColor or args.pointcolor or "#e63946"
		local lblList = args.pointLabels and mw.text.split(tostring(args.pointLabels), "%s*,%s*") or nil
		if latList and lonList then
			mapPoints = {}
			for i = 1, math.min(#latList, #lonList) do
				if latList[i] and lonList[i] then
					mapPoints[#mapPoints + 1] = {
						lat = latList[i],
						lon = lonList[i],
						size = (szList and szList[i]) or defSz,
						color = defCol,
						shape = shapeList and (shapeList[(i - 1) % #shapeList + 1]) or "circle",
						label = lblList and lblList[i] or nil
					}
				end
			end
		end
	end

	local customState = { defs = {}, map = {}, count = 0, allowImgOverlay = true }
	if type(mapPoints) == "table" and #mapPoints > 0 then
		local SVGRenderer = require('Module:Graph/SVGRenderer')
		local baseS = rawDataW / (2 * math.pi)
		local cLon = (center and center[1]) or 0
		local cLat = (center and center[2]) or 0
		local rad = math.pi / 180
		for i, pt in ipairs(mapPoints) do
			local rawX = rawMidX + (pt.lon - cLon) * rad * baseS
			local rawY = -((pt.lat - cLat) * rad * baseS)
			local px = tx + rawX * scaleFactor
			local py = ty + rawY * scaleFactor
			local r = tonumber(pt.size) or 4.5
			local col = pt.color or "#e63946"
			local shp = pt.shape or (shapeList and shapeList[(i - 1) % #shapeList + 1]) or "circle"
			buf[#buf + 1] = SVGRenderer.renderSymbol(shp, px, py, r, col, "#ffffff", 1.0, 0.88, customState)
			if pt.label and pt.label ~= "" then
				buf[#buf + 1] = string.format('<text x="%.2f" y="%.2f" font-family="sans-serif" font-size="10px" font-weight="bold" fill="#222222" data-halo="1">%s</text>',
					px + r + 3, py + 3.5, esc(pt.label))
			end
			if isInteractive and #tipBuf < 250 then
				local ptTitle = (pt.label and pt.label ~= "") and pt.label or string.format("%.2f°, %.2f°", pt.lat, pt.lon)
				local tipHtml = string.format('<b>%s</b> (%.2f, %.2f)', esc(ptTitle), pt.lat, pt.lon)
				local swSpec = { shape = shp, fill = col, stroke = "#ffffff", strokeWidth = 1.0, r = r }
				tipBuf[#tipBuf + 1] = Utils.buildPointHit(px, py, totalW, totalH, tipHtml, col, math.max(14, r * 2 + 4), swSpec)
			end
		end
	end

	-- 繪製標題與日期
	if args.dateLabel then
		buf[#buf + 1] = string.format('<text x="%d" y="28" font-family="sans-serif" font-size="20px" font-weight="bold" fill="steelblue">%s</text>', pad + 4, esc(args.dateLabel))
	end
	if args.title then
		buf[#buf + 1] = string.format('<text x="%.1f" y="25" text-anchor="middle" font-family="sans-serif" font-size="15px" font-weight="bold" fill="#2b2b2b">%s</text>', totalW / 2, esc(args.title))
	end

	if showLegend and isNumbers then
		local barW = math.min(260, math.max(120, mapW * 0.45))
		local barH = 10
		local bx = pad + (mapW - barW) / 2
		local by = topPad + mapH + 10
		buf[#buf + 1] = string.format('<rect x="%.1f" y="%.1f" width="%.1f" height="%d" fill="url(#%s)" stroke="#888888" stroke-width="0.8"/>',
			bx, by, barW, barH, gradId)

		if scaleType == "log" and dMin > 0 and dMax > dMin then
			local logMin = math.log10(dMin)
			local logMax = math.log10(dMax)
			local pStart = math.ceil(logMin - 1e-9)
			local pEnd = math.floor(logMax + 1e-9)
			if pEnd - pStart >= 1 and pEnd - pStart <= 8 then
				for pp = pStart, pEnd do
					local val = 10 ^ pp
					local t = (pp - logMin) / (logMax - logMin)
					local txTick = bx + t * barW
					local anchor = (t < 0.05) and "start" or ((t > 0.95) and "end" or "middle")
					buf[#buf + 1] = string.format('<text x="%.1f" y="%.1f" text-anchor="%s" font-family="sans-serif" font-size="11px" fill="#333333">%g</text>',
						txTick, by + barH + 13, anchor, val)
				end
			else
				local geoMid = math.sqrt(dMin * dMax)
				buf[#buf + 1] = string.format('<text x="%.1f" y="%.1f" text-anchor="start" font-family="sans-serif" font-size="11px" fill="#333333">%g</text>', bx, by + barH + 13, dMin)
				buf[#buf + 1] = string.format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="#333333">%g</text>', bx + barW / 2, by + barH + 13, geoMid)
				buf[#buf + 1] = string.format('<text x="%.1f" y="%.1f" text-anchor="end" font-family="sans-serif" font-size="11px" fill="#333333">%g</text>', bx + barW, by + barH + 13, dMax)
			end
		else
			buf[#buf + 1] = string.format('<text x="%.1f" y="%.1f" text-anchor="start" font-family="sans-serif" font-size="11px" fill="#333333">%g</text>', bx, by + barH + 13, dMin)
			buf[#buf + 1] = string.format('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="sans-serif" font-size="11px" fill="#333333">%g</text>', bx + barW / 2, by + barH + 13, (dMin + dMax) / 2)
			buf[#buf + 1] = string.format('<text x="%.1f" y="%.1f" text-anchor="end" font-family="sans-serif" font-size="11px" fill="#333333">%g</text>', bx + barW, by + barH + 13, dMax)
		end
	end

	-- 若地圖點位使用了自訂 <svg> 圖示,將其 <defs> 插入最前方
	if customState.count > 0 then
		table.insert(buf, 1, string.format('<defs>%s</defs>', table.concat(customState.defs)))
	end

	local isAnimated = (args.animate == nil) or (tostring(args.animate) ~= "no" and tostring(args.animate) ~= "false" and tostring(args.animate) ~= "0")
	local animCls = isAnimated and "gr-anim gr-anim-fade" or nil
	local extraOv = Utils.FlushImgOverlays(customState, totalW, totalH) .. table.concat(tipBuf)
	return require('Module:Graph/SVGRenderer').finalizeSvgWithOverlay(buf, totalW, totalH, 'graph-map-svg', customState.count > 0, extraOv, animCls)
end

function p.exportStaticWorldMap()
	local topo, err = loadTopoJsonTable("Template:Graph:Map/Inner/Worldmap2c-json")
	if not topo then return "錯誤: " .. tostring(err) end
	local decoded = decodeTopoJsonToPaths(topo, "countries", "equirectangular", 100, {0, 0}, 0.35)
	local out = {
		"-- Module:Graph/WorldMapData (Auto-generated static SVG path cache)",
		"return {",
		format("\tbaseScale = 100, minX = %.1f, minY = %.1f, maxX = %.1f, maxY = %.1f,", decoded.minX, decoded.minY, decoded.maxX, decoded.maxY),
		"\tfeatures = {"
	}
	for i = 1, #decoded.features do
		local f = decoded.features[i]
		out[#out + 1] = format("\t\t{ id = %q, d = %q },", f.id, f.d)
	end
	out[#out + 1] = "\t}\n}"
	return "<pre>" .. mw.text.nowiki(table.concat(out, "\n")) .. "</pre>"
end

return p