Module:D3Chart

From Remilia Wiki
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