模組: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, "<[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