模組:Graph/sandbox
| 这是Module:Graph(差异)的沙盒。 |
本模組旨在處理一些資料無法與较新的Chart扩展相容的圖表提供基本的圖表顯示功能及簡易資料懸浮框。若需使用圖表原有的互動性功能,建議转换为Chart扩展:如果是{{Graph:Chart}}原有資料結構或圖表性質有在新版圖表支援的範圍內的图表則建議转换为较新的Chart扩展格式。您可以使用Commons上的MediaWiki提供的简单脚本:graphDataImport.js。 |
| 此模块沙盒已评为alpha版,可接受第三方输入,并可用于少量页面以检查是否存在问题,但需要受到检查。欢迎提供新功能或修改其输入输出机制的建议。 |
本模組為維基百科圖表系統的核心基礎設施。由於 MediaWiki 官方出於安全因素停用了基於 JavaScript/JSON 的 Extension:Graph (Vega) 擴充功能,本模組利用Scribunto的mw.svg程式庫,在純伺服器端重新實作了高效能的圖表與地圖渲染引擎。
模組架構
本系統採用「高內聚、低耦合」的八模組架構設計:
Module:Graph(主路由器與資料變換器)- 負責接收外部調用、解析參數、載入外部資料、過濾髒數據。
- 將扁平化資料 (Long Data) 轉置為渲染器需要的寬表 (Wide Data) 規格(
ChartSpec)。
Module:Graph/SVGRenderer(SVG渲染引擎)- 接收
ChartSpec,並依據資料屬性派發至Module:Graph/ChartRenderer,或現場繪製,也負責SVG節點生成。
- 接收
Module:Graph/ChartRenderer(2D 圖表渲染引擎)- 接收
ChartSpec,主要負責繪製各種類型的圖表再返回Module:Graph/SVGRenderer輸出。
- 接收
Module:Graph/MapRenderer(地圖渲染引擎)- 負責讀取 TopoJSON,執行麥卡托 (Mercator) 等地理投影,並支援亞像素過濾與靜態 SVG 向量字典快取(WorldMapData)。
Module:Graph/RenderUtils(渲染共用工具集)- 提供
Module:Graph/SVGRenderer、Module:Graph/ChartRenderer、Module:Graph/MapRenderer共用函數和工具集。主要負責處理比例尺(Linear/Log/Pow/Sqrt)、座標軸漂亮刻度計算(Nice Ticks)、單調三次樣條插值(Monotone Cubic Spline)。
- 提供
Module:Graph/MathEngine(數學運算引擎與工具集)- 提供與數學運算相關的共用函數和工具集。
Module:Graph/Expression(閹割版JavaScript引擎)- 負責處理來自舊vega中的JavaScript運算式的圖靈不完全(Turing-incomplete)的單行JavaScript運算式解析器。其僅支援JavaScript求值和閉包函數功能,其餘所有流程控制功能皆不提供。本模組的目的僅為了相容舊vega資料處理語法而設計。若用了不提供的JavaScript語法,將自動降級顯示MediaWiki官方的圖表不可用警示。
Module:Graph/Wikidata(維基數據資料讀取)- 專為
<graph>圖表設計之輕量型 Wikidata SPARQL 直譯與資料萃取引擎。
- 專為
五階段外部資料路由管線 (Data Pipeline)
本模組具備強大的外部資料抓取能力。當偵測到 dataUrl 參數或 JSON 中的 url 屬性時,會透過 fetchExternalData 函數進行安全路由:
- 路由 A(網路 API):若開頭為
wikirest://(如 PageViews)或http://,由於 Lua 沙盒限制無法對外連線,將直接觸發 Fallback (優雅降級),交還給 MediaWiki 系統顯示官方停用警語。 - 路由 B(Data: 命名空間):透過
pcall(mw.ext.data.get)拉取維基共享資源的 Tabular Data。 - 路由 C(Module: 命名空間):透過
pcall(mw.loadData)載入靜態 Lua Table,避免重複解析。 - 路由 D(原生 JSON 快取):檢查頁面的
contentModel == "json",若相符則優先呼叫mw.loadJsonData,直接利用 MediaWiki 內建的 JSON 快取通道。 - 路由 E(一般維基代碼):對於包含維基模板語法的 JSON,先呼叫
frame:preprocess()展開模板參數,接著通過內建的 字串狀態機註解剝離器 抹除//與/* */註解,最後才送入mw.text.jsonDecode,確保解析不崩潰。
針對 Lua 5.1 代理表的防呆設計
由於 MediaWiki 的 Scribunto (Lua 5.1) 中,mw.loadData 與 mw.loadJsonData 回傳的是帶有 Metatable 的唯讀代理表(Proxy Table),直接對其使用 # 運算子(如 #features 或 #data)永遠會得到 0。
本系統的資料遍歷全面改用 while table[idx] ~= nil do 迭代,徹底避開此陷阱,確保資料不會因長度運算子失效而全部遺失。
開發者 API 介面
| 方法名稱 | 說明 |
|---|---|
p.chart(frame)
|
標準圖表入口。負責合併 arguments,抓取外部資料並呼叫 SVGRenderer。 |
p.vega(frame)
|
Vega 相容入口。接受單一無名參數傳入的 Vega JSON 字串,模組會自動將其解析並轉譯為內部參數表,無縫輸出 SVG。 |
p.map(frame)
|
地圖入口。將參數轉交給 Module:Graph/MapRenderer 進行處理。
|
p.chartDebuger(frame)
|
偵錯模式。除了輸出 SVG 圖表外,還會在畫面上以文字方式 Dumping 輸出一份完整的 ChartSpec Table 結構,方便開發者驗證資料變換(Data Wrangling)的結果是否正確。
|
舊版說明文檔
| 此模块沙盒已被保护。此高度可见模块沙盒具有高風險,或已用于大量頁面或被频繁替换引用。由于破坏或失误影响程度嚴重,因此已被保护,只有錯誤:頁面未被保護。可以編輯。 |
| 相关页面 |
|---|
Module with helper functions for the Graph extension to display graphs and maps. From de:Modul:Graph.
Functions for templates
map
Creates a JSON object for <graph> to display a political map with colored highlights. In the article namespace the template {{Graph:Map}} should be used instead. See its page for use cases.
Maps can be found at Special:Prefixindex/Template:Graph:Map/Inner/ (for example Template:Graph:Map/Inner/Worldmap2c-json with country borders) and new maps should also be saved under Module:Graph/.
Parameters:
- basemap: sets the base map. The map definitions must follow the TopoJSON format and if saved in Wikipedia are available for this module. Maps in the default directory Special:Prefixindex/Template:Graph:Map/Inner/ like Template:Graph:Map/Inner/Worldmap2c-json should only be referenced by their name while omitting the Module:Graph/ prefix to allow better portability. The parameter also accepts URLs, e.g. maps from other Wikipedia versions (the link should follow the scheme of
//en.wikipedia.org/w/index.php?title=mapname&action=raw, i.e. protocol-relative without leading http/s and a trailing action=raw to fetch the raw content only). URLs to maps on external sites should be avoided for the sake of link stability, performance, security, and she be assumed to be blocked by the software or browser anyway. - scale: the scaling factor of the map (default: 100)
- projection: the map projection to use. Supported values are listed at https://github.com/d3/d3-geo-projection. The default value is
equirectangularfor an equirectangular projection. - center: map center (corresponds in the map data to both comma-separated values of the
scalefield) - feature: which geographic objects should be displayed (corresponds in the map data to the name of the field under the
objectsfield). The default is valuecountries. - ids of geographic entities: The actual parameter names depend on the base map and the selected feature. For example, for the above mentioned world map the ids are ISO country codes. The values can be either colors or numbers in case the geographic entities should be associated with numeric data:
DE=lightbluemarks Germany in light blue color, andDE=80.6assigns Germany the value 80.6 (population in millions). In the latter case, the actual color depends on the following parameters. - colorScale: the color palette to use for the color scale. The palette must be provided as a comma-separated list of color values. The color values must be given either as
#rgb/#rrggbbor by a CSS color name. Instead of a list, the built-in color palettescategory10andcategory20can also be used. - scaleType: supported values are
linearfor a linear mapping between the data values and the color scale,logfor a log mapping,powfor a power mapping (the exponent can be provided aspow 0.5),sqrtfor a square-root mapping, andquantizefor a quantized scale, i.e. the data is grouped in as many classes as the color palette has colors. - domainMin: lower boundary of the data values, i.e. smaller data values are mapped to the lower boundary
- domainMax: upper boundary of the data values, i.e. larger data values are mapped to the upper boundary
- legend: show color legend (does not work with
quantize) - defaultValue: default value for unused geographic entities. In case the id values are colors the default value is
silver, in case of numbers it is 0. - formatjson: format JSON object for better legibility
chart
Creates a JSON object for <graph> to display charts. In the article namespace the template Template:Graph:Chart should be used instead. See its page for use cases.
Parameters:
- width: width of the chart
- height: height of the chart
- type: type of the chart:
linefor line charts,areafor area charts, andrectfor (column) bar charts, andpiefor pie charts. Multiple series can stacked using thestackedprefix, e.g.stackedarea. - interpolate: interpolation method for line and area charts. It is recommended to use
monotonefor a monotone cubic interpolation – further supported values are listed at https://github.com/nyurik/vega/wiki/Marks#line. - colors: color palette of the chart as a comma-separated list of colors. The color values must be given either as
#rgb/#rrggbb/#aarrggbbor by a CSS color name. For#aarrggbbtheaacomponent denotes the alpha channel, i.e. FF=100% opacity, 80=50% opacity/transparency, etc. (The default color palette if n <= 10 is Category10: Template:ChartColors else is Category20: Template:ChartColors). See Template:ChartColors for details. - xAxisTitle and yAxisTitle: captions of the x and y axes
- xAxisMin, xAxisMax, yAxisMin, and yAxisMax: minimum and maximum values of the x and y axes (not yet supported for bar charts). These parameters can be used to invert the scale of a numeric axis by setting the lowest value to the Max and highest value to the Min.
- xAxisFormat and yAxisFormat: changes the formatting of the axis labels. Supported values are listed at https://github.com/d3/d3-3.x-api-reference/blob/master/Formatting.md#numbers for numbers. For example, the format
%can be used to output percentages. For date/time specification of supported values is https://github.com/d3/d3-3.x-api-reference/blob/master/Time-Formatting.md , e.g.xAxisFormat=%d-%m-%Yfor result 13-01-1977. - xAxisAngle: rotates the x axis labels by the specified angle. Recommended values are: -45, +45, -90, +90
- xType and yType: data types of the values, e.g.
integerfor integers,numberfor real numbers,datefor dates (e.g. YYYY-MM-DD), andstringfor ordinal values (usestringto prevent axis values from being repeated when there are only a few values). Remarks:Datetype doesn't work for bar graphs. Fordatedata input please use ISO date format (e.g. YYYY-MM-DD) acc. to date and time formats used in HTML. Other date formats may work but not in all browsers. Date is unfortunately displayed only in en-US format for all Wikipedia languages. Workaround is to use xAxisFormat and yAxisFormat with numerical dates format. - xScaleType and yScaleType: scale types of the x and y axes, e.g.
linearfor linear scale (default),logfor logarithmic scale andsqrtfor square root scale.- A logarithmic chart allows only positive values to be plotted. A square root scale chart cannot show negative values.
- x: the x-values as a comma-separated list, for dates and time see remark in xType and yType
- y or y1, y2, …: the y-values for one or several data series, respectively. For pie charts
y2denotes the radius of the corresponding sectors. For dates and time see remark in xType and yType - legend: show legend (only works in case of multiple data series)
- y1Title, y2Title, …: defines the label of the respective data series in the legend
- linewidth: line width for line charts or distance between the pie segments for pie charts. Setting to 0 with
type=linecreates a scatter plot. - linewidths: different line widths may be defined for each series of data with csv, if set to 0 with "showSymbols" results with points graph, eg.:
linewidths=1, 0, 5, 0.2 - showSymbols: show symbol on data point for line graphs, if a number is provided, the symbol size (default 2.5) may be defined for each data series, eg.:
showSymbols=1, 2, 3, 4 - symbolsShape: custom shape for symbol: circle, x, square, cross, diamond, triangle_up, triangle_down, triangle_right, triangle_left. May be defined for each series of data with csv, eg.:
symbolsShape= circle, cross, square - symbolsNoFill: if true symbol will be without fill (only stroke),
- symbolsStroke: if "x" symbol is used or option "symbolsNoFill" symbol stroke width, default 2.5
- showValues: Additionally, output the y values as text. (Currently, only (non-stacked) bar and pie charts are supported.) The output can be configured used the following parameters provided as
name1:value1, name2:value2(e.g.|showValues=fontcolor:blue,angle:0).- format: Format the output according to https://github.com/d3/d3-3.x-api-reference/blob/master/Formatting.md#numbers for numbers and https://github.com/d3/d3-3.x-api-reference/blob/master/Time-Formatting.md for date/time.
- fontcolor: text color
- fontsize: text size
- offset: move text by the given offset. For bar charts and pie charts with
midanglethis also defines if the text is inside or outside the chart. - angle (pie charts only): text angle in degrees or
midangle(default) for dynamic angles based on the mid-angle of the pie sector.
- innerRadius: For pie charts: defines the inner radius to create a doughnut chart.
- xGrid and yGrid: display grid lines on the x and y axes.
- Annotations
- vAnnotationsLine and hAnnotationsLine: display vertical or horizontal annotation lines on specific values e.g.
hAnnotationsLine=4, 5, 6 - vAnnotationsLabel and hAnnotationsLabel: display vertical or horizontal annotation labels for lines e.g.
hAnnotationLabel = label1, label2, label3
- vAnnotationsLine and hAnnotationsLine: display vertical or horizontal annotation lines on specific values e.g.
- formatjson: format JSON object for better legibility
Template wrappers
The functions mapWrapper and chartWrapper are wrappers to pass all parameters of the calling template to the respective map and chart functions.
Note: In the editor preview the graph extension creates a Canvas (HTML元素) with vector graphics. However, when saving the page a PNG raster graphics is generated instead. {{#invoke:Graph|function_wrapper_name}}
-- 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, "<[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