Module:D3Chart
Jump to navigation
Jump to search
Documentation for this module may be created at Module:D3Chart/doc
--[[
D3Chart - LaTeX/TikZ Style Scientific Data Visualization Module
Generates D3.js chart containers from Cargo queries or manual data.
Used with the D3Charts.js ResourceLoader module.
Styled to emulate pgfplots/TikZ publication-quality aesthetics.
INSTALLATION:
1. Create page: Module:D3Chart
2. Paste this entire file contents
3. Save
USAGE:
{{#invoke:D3Chart|bar|table=MyTable|label=Name|value=Count}}
{{#invoke:D3Chart|pie|table=Characters|label=Faction|value=_pageName|aggregate=count}}
{{#invoke:D3Chart|line|data=Q1:100,Q2:150,Q3:200,Q4:180|title=Quarterly Sales}}
CHART TYPES:
- bar : Vertical bar chart
- hbar : Horizontal bar chart
- line : Line chart (supports multiple series)
- pie : Pie chart
- donut : Donut chart (pie with hole)
- scatter : Scatter plot (requires x, y fields)
- area : Area chart
BASIC OPTIONS:
- title : Chart title
- xLabel : X-axis label
- yLabel : Y-axis label
- width : Chart width in pixels (default: 600)
- height : Chart height in pixels (default: 400)
- showLegend : Show legend (default: true for pie/donut)
- colors : Custom colors, comma-separated (e.g., "#ff0000,#00ff00")
TIKZ/PGFPLOTS STYLE OPTIONS:
- showFrame : Draw box around plot area (default: false)
- gridStyle : Grid line style: 'dotted', 'dashed', 'solid', 'none' (default: dotted)
- animate : Enable animations (default: false for static scientific look)
- smooth : Smooth curves for line/area charts (default: false for sharp lines)
- colorblind : Use colorblind-friendly palette (default: false)
- markerSize : Data point marker radius in pixels (default: 3)
EXAMPLES:
{{#invoke:D3Chart|line|data=1:2,2:4,3:8|showFrame=true|gridStyle=dotted}}
{{#invoke:D3Chart|scatter|data=1:1,2:4,3:9|colorblind=true|markerSize=4}}
{{#invoke:D3Chart|bar|data=A:10,B:20,C:15|animate=true|gridStyle=dashed}}
@author Remilia Wiki
@license MIT
@see https://pgfplots.sourceforge.io/
]]
local p = {}
local cargo = mw.ext.cargo
-- ==========================================================================
-- UTILITY FUNCTIONS
-- ==========================================================================
--- Trim whitespace from string
local function trim(s)
if s == nil then return nil end
return s:match("^%s*(.-)%s*$")
end
--- Escape string for JSON
local function jsonEscape(s)
if s == nil then return "" end
s = tostring(s)
s = s:gsub('\\', '\\\\')
s = s:gsub('"', '\\"')
s = s:gsub('\n', '\\n')
s = s:gsub('\r', '\\r')
s = s:gsub('\t', '\\t')
return s
end
--- Convert Lua table to JSON string
local function toJson(tbl)
local json = mw.text.jsonEncode(tbl)
return json
end
--- Parse comma-separated key:value pairs into data array
local function parseManualData(dataStr)
local data = {}
if dataStr == nil or dataStr == "" then
return data
end
for pair in dataStr:gmatch("[^,]+") do
pair = trim(pair)
local label, value = pair:match("^(.+):(.+)$")
if label and value then
table.insert(data, {
label = trim(label),
value = tonumber(trim(value)) or 0
})
end
end
return data
end
--- Parse scatter data (x:y or label:x:y format)
local function parseScatterData(dataStr)
local data = {}
if dataStr == nil or dataStr == "" then
return data
end
for pair in dataStr:gmatch("[^,]+") do
pair = trim(pair)
-- Try label:x:y format first
local label, x, y = pair:match("^(.+):([^:]+):([^:]+)$")
if label and x and y then
table.insert(data, {
label = trim(label),
x = tonumber(trim(x)) or 0,
y = tonumber(trim(y)) or 0
})
else
-- Try x:y format
x, y = pair:match("^([^:]+):([^:]+)$")
if x and y then
table.insert(data, {
x = tonumber(trim(x)) or 0,
y = tonumber(trim(y)) or 0
})
end
end
end
return data
end
-- ==========================================================================
-- CARGO QUERY FUNCTIONS
-- ==========================================================================
--- Query Cargo and return data for label/value charts
local function queryCargoLabelValue(args)
local tables = args.table or args.tables
local labelField = args.label or args.field or "_pageName"
local valueField = args.value or "1"
local aggregate = args.aggregate
local where = args.where
local groupBy = args.groupBy or args.group
local orderBy = args.orderBy or args.order
local limit = args.limit or "50"
local join = args.join
if not tables then
return nil, "No table specified"
end
-- Build fields clause
local fields
if aggregate == "count" then
fields = labelField .. "=label,COUNT(*)=value"
groupBy = groupBy or labelField
elseif aggregate == "sum" then
fields = labelField .. "=label,SUM(" .. valueField .. ")=value"
groupBy = groupBy or labelField
elseif aggregate == "avg" then
fields = labelField .. "=label,AVG(" .. valueField .. ")=value"
groupBy = groupBy or labelField
else
fields = labelField .. "=label," .. valueField .. "=value"
end
-- Build query args
local queryArgs = {
where = where,
groupBy = groupBy,
orderBy = orderBy or "value DESC",
limit = limit
}
if join then
queryArgs.join = join
end
-- Execute query
local results = cargo.query(tables, fields, queryArgs)
if not results then
return nil, "Cargo query returned no results"
end
-- Transform results
local data = {}
for _, row in ipairs(results) do
table.insert(data, {
label = row.label or "Unknown",
value = tonumber(row.value) or 0
})
end
return data
end
--- Query Cargo and return data for scatter plots
local function queryCargoScatter(args)
local tables = args.table or args.tables
local xField = args.x or "x"
local yField = args.y or "y"
local labelField = args.label
local categoryField = args.category
local where = args.where
local orderBy = args.orderBy or args.order
local limit = args.limit or "100"
if not tables then
return nil, "No table specified"
end
-- Build fields clause
local fields = xField .. "=x," .. yField .. "=y"
if labelField then
fields = fields .. "," .. labelField .. "=label"
end
if categoryField then
fields = fields .. "," .. categoryField .. "=category"
end
-- Execute query
local results = cargo.query(tables, fields, {
where = where,
orderBy = orderBy,
limit = limit
})
if not results then
return nil, "Cargo query returned no results"
end
-- Transform results
local data = {}
for _, row in ipairs(results) do
local point = {
x = tonumber(row.x) or 0,
y = tonumber(row.y) or 0
}
if row.label then
point.label = row.label
end
if row.category then
point.category = row.category
end
table.insert(data, point)
end
return data
end
-- ==========================================================================
-- CHART RENDERING
-- ==========================================================================
--- Build config object from args
local function buildConfig(args)
local config = {}
-- Basic options
if args.title then config.title = args.title end
if args.xLabel then config.xLabel = args.xLabel end
if args.yLabel then config.yLabel = args.yLabel end
if args.width then config.width = tonumber(args.width) end
if args.height then config.height = tonumber(args.height) end
if args.showLegend ~= nil then config.showLegend = (args.showLegend ~= "false" and args.showLegend ~= "0") end
if args.pointRadius then config.pointRadius = tonumber(args.pointRadius) end
-- TikZ/pgfplots style options
if args.showFrame ~= nil then
config.showFrame = (args.showFrame == "true" or args.showFrame == "1" or args.showFrame == "yes")
end
if args.gridStyle then
config.gridStyle = args.gridStyle -- 'dotted', 'dashed', 'solid', 'none'
end
if args.animate ~= nil then
config.animate = (args.animate == "true" or args.animate == "1" or args.animate == "yes")
end
if args.smooth ~= nil then
config.smooth = (args.smooth == "true" or args.smooth == "1" or args.smooth == "yes")
end
if args.colorblind ~= nil then
config.colorblind = (args.colorblind == "true" or args.colorblind == "1" or args.colorblind == "yes")
end
if args.markerSize then
config.markerSize = tonumber(args.markerSize)
end
if args.markerStyle then
config.markerStyle = args.markerStyle -- 'filled', 'open', 'none'
end
-- Parse custom colors
if args.colors then
config.colors = {}
for color in args.colors:gmatch("[^,]+") do
table.insert(config.colors, trim(color))
end
end
-- Parse series for line charts
if args.series then
config.series = {}
for seriesDef in args.series:gmatch("[^;]+") do
local key, name = seriesDef:match("^([^:]+):(.+)$")
if key then
table.insert(config.series, {
key = trim(key),
name = trim(name)
})
end
end
end
return config
end
--- Render the chart container HTML
local function renderChart(chartType, config, data)
local configJson = toJson(config)
local dataJson = toJson(data)
-- Build HTML container
local html = mw.html.create('div')
:addClass('d3-chart')
:attr('data-type', chartType)
:attr('data-config', configJson)
:attr('data-values', dataJson)
-- Add fallback message (cleared by JS)
html:tag('div')
:addClass('d3-chart-fallback')
:wikitext('▶ Chart requires JavaScript to display.')
return tostring(html)
end
-- ==========================================================================
-- PUBLIC API
-- ==========================================================================
--- Generic chart function (called by specific chart functions)
local function chart(chartType, frame)
local args = frame.args
-- Handle parent frame args (for template usage)
if not args.table and not args.data then
args = frame:getParent().args
end
local config = buildConfig(args)
local data, err
-- Determine data source
if args.data then
-- Manual data input
if chartType == "scatter" then
data = parseScatterData(args.data)
else
data = parseManualData(args.data)
end
elseif args.table or args.tables then
-- Cargo query
if chartType == "scatter" then
data, err = queryCargoScatter(args)
else
data, err = queryCargoLabelValue(args)
end
else
return '<p class="d3-chart-error">Error: No data source specified. Use table= for Cargo or data= for manual input.</p>'
end
if err then
return '<p class="d3-chart-error">Error: ' .. err .. '</p>'
end
if not data or #data == 0 then
return '<p class="d3-chart-error">No data available for chart.</p>'
end
return renderChart(chartType, config, data)
end
-- Chart type functions
function p.bar(frame) return chart("bar", frame) end
function p.hbar(frame) return chart("hbar", frame) end
function p.line(frame) return chart("line", frame) end
function p.pie(frame) return chart("pie", frame) end
function p.donut(frame) return chart("donut", frame) end
function p.scatter(frame) return chart("scatter", frame) end
function p.area(frame) return chart("area", frame) end
--- Generic entry point
function p.chart(frame)
local args = frame.args
if not args.type and frame:getParent() then
args = frame:getParent().args
end
local chartType = args.type or args[1] or "bar"
return chart(chartType, frame)
end
return p