Jump to content

Module:Weather/sandbox

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Erutuon (talk | contribs) at 19:39, 21 October 2017 (shortening). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

local p = {}

require('Module:No globals')

local degree = "°" -- used by addUnitNames()
local minus = "−" -- used by makeRow() and makeTable()
local thinSpace = mw.ustring.char(0x2009) -- used by makeCell()

local gsub = mw.ustring.gsub

local precision, decimals

-- String-handling function
local function checkForString(var)
	var = tostring(var)
	if var == "" then
		return nil
	else
		return var
	end
end

-- Error message handling
local message = ""

local function addMessage(newMessage)
	if checkForString(message) then
		message = message .. " " .. newMessage
	else
		message = "Notices: " .. newMessage
	end
end

local function monospace(str)
	return '<span style="background-color: #EEE; font-family: monospace;">' .. str .. '</span>'
end

-- Input and output parameters
local function getFormat(inputParameter, outputParameter, palette, messages)
	local length, inputUnit, outputUnit, palette, show, cellFormat
	
	if inputParameter == nil then
		error('Please provide the number of values and a unit in the input parameter')
	else
		-- Find as many as two digits in the input parameter.
		length = tonumber(string.match(inputParameter, "(%d%d?)")) 
		if not length then
			length = 13
			addMessage('getFormat has not found a length value in the input parameter; length defaults to "13"')
		end
		
		-- Find C or F, but not both
		if string.find(inputParameter, "C") and string.find(inputParameter, "F") then
			error("Input unit must be either C (Celsius) or F (Fahrenheit)")
		else
			inputUnit = string.match(inputParameter, "([CF])") or error("Please provide an input unit in the input parameter: F for Fahrenheit or C for Celsius", 0)
		end
		
		if inputUnit == "C" then
			outputUnit = "F"
		else
			outputUnit = "C"
		end
		
		-- Make sure nothing except C, F, numbers, or spaces is in the input parameter.
		if string.find(inputParameter, "[^CF%d%s]") then
			addMessage("There are extraneous characters in the " .. monospace("output") .. " parameter.")
		end
	end
	
	if outputParameter == nil then
		-- Since there are default values, the module will still generate output with an empty output parameter.
		addMessage("No output format has been provided in the " .. monospace("output") .. " parameter, so default values will be used.")
	else
		cellFormat = {}
		for i, unit in require("Module:StringTools").imatch(outputParameter, "[CF]") do
			cellFormat[i] = unit
			if i > 2 then
				break
			end
		end
		local function setFormat(key, variable, value)
			if string.find(outputParameter, key) then
				cellFormat[variable] = value
			else
				cellFormat[variable] = not value
			end
		end
		if cellFormat[1] then
			cellFormat.first = cellFormat[1]
		else
			error('C or F not found in output parameter')
		end
		if cellFormat[2] == nil then
			cellFormat["convertUnits"] = false
		else
			if cellFormat[2] == cellFormat[1] then
				error('There should not be two of the same unit name in the output parameter.')
			else
				cellFormat["convertUnits"] = true
			end
		end
		setFormat("unit", "unitNames", true)
		setFormat("no ?color", "color", false)
		setFormat("sort", "sortable", true)
		setFormat("full ?size", "smallFont", false)
		setFormat("no ?brackets", "brackets", false)
		setFormat("round", "decimals", "0", "")
		if string.find(outputParameter, "line break") then
			cellFormat["lineBreak"] = true
		elseif string.find(outputParameter, "one line") then
			cellFormat["lineBreak"] = false
		else
			cellFormat["lineBreak"] = "auto"
		end
		if string.find(outputParameter, "one line") and
			string.find(outputParameter, "line break") then
			error('Place either "one line" or "line break" in the output parameter, not both')
		end
	end
	
	palette = palette or "cool2avg"
	
	show = messages == "show"
	
	return {
		length = length, inputUnit = inputUnit, outputUnit = outputUnit,
		cellFormat = cellFormat, show = show, palette = palette
	}
end

-- Math functions

local function round(value, decimals)
	value = tonumber(value)
	if type(value) == "number" then
		return string.format("%." .. decimals .. "f", value)
	else
		addMessage("Format was asked to operate on " .. tostring(value) .. ", which cannot be converted to a number.")
		return ""
	end
end

local function convert(value, unit, decimals) -- Unit is the unit being converted from.
	if not unit then
		error("No unit supplied to convert.", 2)
	end
	if tonumber(value) then
		local value = tonumber(value)
		if unit == "C" then
			addMessage(value .. " " .. degree .. unit .. " was converted.")
			return round(value * 9/5 + 32, decimals)
		elseif unit == "F" then
			addMessage(value .. " " .. degree .. unit .. " was converted.")
			return round((value - 32) * 5/9, decimals)
		else
			error("Input unit not recognized", 2)
		end
	else
		-- to avoid concatenation errors
		return "" 
	end
end

-- Input parsing
local function makeArray(parameter, array, frame)
	local format = getFormat(frame.args.input, frame.args.output, frame.args.palette, frame.args.messages)
	local array = {}
	local hasDecimals = false
	
	local numbers = mw.text.split(parameter, "%s+")
	if not numbers[format.length] then
		addMessage('There are not ' .. format.length .. ' values in the ' .. parameter .. ' parameter.')
	end
	
	for i, number in ipairs(numbers) do
		local decimals = number:match("^%-?%d%d?%d?.?(%d?)$")
		if decimals then
			if decimals ~= "" then
				hasDecimals = true
			end
		else
			error('The number "' .. number .. '" does not fit the expected pattern.')
		end
		
		table.insert(array, number)
	end
	
	-- If a value with a decimal point was found somewhere, set precision to 1.
	-- The possibility of two decimal points will be excluded, at least for now.
	if hasDecimals then
		precision = "1"
	else
		precision = "0"
	end
	
	format.precision = precision
	
	return array
end

local a, b, c

local function makeArrays(frame)
	local format = getFormat(frame.args.input, frame.args.output, frame.args.palette, frame.args.messages)
	local parameter_a = frame.args.a
	local parameter_b = frame.args.b
	local parameter_c = frame.args.c
	if parameter_a then
		a = makeArray(parameter_a, a, frame)
	else
		error('Please provide a set of numbers in parameter a')
	end
	if parameter_b then
		b = makeArray(parameter_b, b, frame)
	else
		addMessage("There is no content in parameter " .. monospace("b") .. ".")
	end
	if parameter_c then
		c = makeArray(parameter_c, c, frame)
	else
		addMessage("There is no content in parameter " .. monospace("c") .. ".")
	end
	return a, b, c
end

-- Color generation

p.palettes = {
	--[[
		The first three arrays in each palette defines background color using a
		table of four numbers, say { 11, 22, 33, 44 } (values in °C).
		That means that, on the scale from 0 (black) to 255 (saturated), the color
		is 0 below 11°C and above 44°C, and is 255 from 22°C to 33°C.
		The color rises from 0 to 255 between 11°C and 22°C, and falls from 255 to 0
		between 33°C and 44°C.
	]]
	cool = {
		{ -42.75,   4.47, 41.5, 60   }, -- red
		{ -42.75,   4.47,  4.5, 41.5 }, -- green
		{ -90   , -42.78,  4.5, 23   }, -- blue
		white = { -23.3, 37.8 },		-- background
	},
	cool2 = {
		{ -42.75,   4.5 , 41.5, 56   },
		{ -42.75,   4.5 ,  4.5, 41.5 },
		{ -90   , -42.78,  4.5, 23   },
		white = { -23.3, 35 },
	},
	cool2avg = {
		{ -38,   4.5, 25  , 45   },
		{ -38,   4.5,  4.5, 30   },
		{ -70, -38  ,  4.5, 23   },
		white = { -23.3, 25 },
	},
}

local function temperatureColor(palette, value, outRGB)
	--[[ Return style for a table cell based on the given value which
		should be a temperature in °C. ]]
	local backgroundColor, textColor
	value = tonumber(value)
	if value == nil then
		backgroundColor, textColor = 'FFF', '000'
		addMessage("Value supplied to " .. monospace("temperatureColor") .. " is not recognized.")
	else
		local min, max = unpack(palette.white or { -23, 35 })
		if value < min or value >= max then
			textColor = 'FFF'
		else
			textColor = '' -- This assumes that black text color is the default for most readers.
		end

		local backgroundRGB = outRGB or {}
		for i, v in ipairs(palette) do
			local a, b, c, d = unpack(v)
			if value <= a then
				backgroundRGB[i] = 0
			elseif value < b then
				backgroundRGB[i] = (value - a) * 255 / (b - a)
			elseif value <= c then
				backgroundRGB[i] = 255
			elseif value < d then
				backgroundRGB[i] = 255 - ( (value - c) * 255 / (d - c) )
			else
				backgroundRGB[i] = 0
			end
		end
		backgroundColor = string.format('%02X%02X%02X', backgroundRGB[1], backgroundRGB[2], backgroundRGB[3])
	end
	if textColor == "" then
		return backgroundColor
	else
		return backgroundColor, textColor
	end
end

local function colorCSS(backgroundColor, textColor)
	if backgroundColor and textColor then
		return 'background: #' .. backgroundColor .. '; color: #' .. textColor .. ';'
	elseif backgroundColor then
		return 'background: #' .. backgroundColor .. ';'
	else
		return ''
	end
end

local function temperatureColorCSS(palette, value, outRGB)
	return colorCSS(temperatureColor(palette, value, outRGB))
end

local function temperatureCSS(value, unit, palette)
	local palette = p.palettes[palette] or p.palettes.cool
	local value = tonumber(value)
	if value == nil then
		error("The function " .. monospace("temperatureCSS") .. " is receiving a nil value")
	else
		if unit == 'C' then
			return colorCSS(temperatureColor(palette, value))
		elseif unit == 'F' then
			return colorCSS(temperatureColor(palette, convert(value, 'F', decimals)))
		else
			unitError(unit or "nil")
		end
	end
end

local function styleAttribute(palette, value, outRGB)
	local fontSize = "font-size: 85%;"
	local color = temperatureColorCSS(palette, value, outRGB)
	return 'style=\"' .. color .. ' ' .. fontSize .. '\"'
end

local style_attribute = styleAttribute

--[=[
	Used by {{Average temperature table/row/C/sandbox}},
	{{Average temperature table/row/F/sandbox}},
	{{Average temperature table/row/C/sandbox}},
	{{Template:Avg temp row F/sandbox2}},
	{{Template:Avg temp row C/sandbox2}}.
]=]
function p.temperatureStyle(frame)
	local palette = p.palettes[frame.args.palette] or p.palettes.cool
	local unit = frame.args.unit or 'C'
	local value = tonumber(frame.args[1])
	if unit == 'C' then
		return styleAttribute(palette, value)
	elseif unit == 'F' then
		return styleAttribute(palette, convert(value, 'F', 1))
	else
		unitError(unit)
	end
end

p.temperature_style = p.temperatureStyle

--[[ ==== Cell, row, table generation ==== ]]
local outputFormats = {
	high_low_average_F =
		{ first = "F",
		convertUnits = true,
		unitNames = false,
		color = true,
		smallFont = true,
		sortable = true,
		decimals = "0",
		brackets = true,
		lineBreak = "auto", },
	high_low_average_C =
		{ first = "C",
		convertUnits = true,
		unitNames = false,
		color = true,
		smallFont = true,
		sortable = true,
		decimals = "0",
		brackets = true,
		lineBreak = "auto", },
	high_low_F =
		{ first = "F",
		convertUnits = true,
		unitNames = false,
		color = false,
		smallFont = true,
		sortable = false,
		decimals = "",
		brackets = true,
		lineBreak = "auto", },
	high_low_C =
		{ first = "C",
		convertUnits = true,
		unitNames = false,
		color = false,
		smallFont = true,
		sortable = false,
		decimals = "0",
		brackets = true,
		lineBreak = "auto", },
	average_F =
		{ first = "F",
		convertUnits = true,
		unitNames = false,
		color = true,
		smallFont = true,
		sortable = false,
		decimals = "0",
		brackets = true,
		lineBreak = "auto", },
	average_C =
		{ first = "C",
		convertUnits = true,
		unitNames = false,
		color = true,
		smallFont = true,
		sortable = false,
		decimals = "0",
		brackets = true,
		lineBreak = "auto", },
	}

local outputFormat

local function addUnitNames(value, yesOrNo, unit)
	if not unit then
		error("No unit supplied as argument 3 to addUnitNames", 2)
	end
	-- Don't add a unit name to an empty string
	value = yesOrNo == true and checkForString(value) and value .. "&nbsp;" .. degree .. unit or value
	return value
end

local function ifYes(parameter, realization1, realization2)
	local result
	if realization1 then
		if realization2 then
			result = parameter == true and { realization1, realization2 } or { "", "" }
		else
			result = parameter == true and realization1 or ""
		end
	else
		result = ""
		addMessage(monospace("ifYes") .. " needs at least one realization.")
	end
	return result
end

local function makeCell(outputFormat, a, b, c, format)
	local cell, cellContent = "", ""
	local colorCSS, otherCSS, titleAttribute, sortkey, attributeSeparator, convertedUnitsSeparator = "", "", "", "", "", "", ""
	local styleAttribute, highLowSeparator, brackets, values, convertedUnits = {"", ""}, {"", ""}, {"", ""}, {"", ""}, {"", ""}
	-- Distinguish styleAttribute variable from styleAttribute function above.
	decimals = ( tonumber(outputFormat.decimals) and outputFormat.decimals ) or precision
		--[[ Precision is the number of decimals in the first number of the last array.
			This may be a problem for data from Weatherbase,
			which seems to inappropriately remove .0 from numbers that have it. ]]
	
	if tonumber(b) and tonumber(a) then
		values, highLowSeparator = { round(a, decimals), round(b, decimals) }, { thinSpace .. "/" .. thinSpace, ifYes(outputFormat.convertUnits, thinSpace .. "/" .. thinSpace) }
	elseif tonumber(a) then
		values = { round(a, decimals), "" }
	elseif tonumber(c) then
		values = { round(c, decimals), "" }
	end
	mw.log("format.outputUnit = " .. tostring(format.outputUnit))
	if outputFormat.first == format.inputUnit then
		if outputFormat.convertUnits == true then
			convertedUnits = { addUnitNames(convert(values[1], format.inputUnit, decimals), outputFormat.unitNames, format.outputUnit), addUnitNames(convert(values[2], format.inputUnit, decimals), outputFormat.unitNames, format.outputUnit) }
		end
		values = { addUnitNames(values[1], outputFormat.unitNames, format.inputUnit), addUnitNames(values[2], outputFormat.unitNames, format.inputUnit) }
	elseif outputFormat.first == "C" or outputFormat.first == "F" then
		if outputFormat.convertUnits == true then
			convertedUnits = { addUnitNames(values[1], outputFormat.unitNames, format.inputUnit), addUnitNames(values[2], outputFormat.unitNames, format.inputUnit) }
		end
		values = { addUnitNames(convert(values[1], format.inputUnit, decimals), outputFormat.unitNames, format.outputUnit), addUnitNames(convert(values[2], format.inputUnit, decimals), outputFormat.unitNames, format.outputUnit) }
	else
		addMessage(monospace(tostring(outputFormat.first)) .. ", the value for " .. monospace("first") .. " in " .. monospace("outputFormat") .. " is not recognized.")
	end
	--[[
		Regarding line breaks:
		If there are two values, there will be at least three characters: 9/1.
		If there is one decimal, numbers will be three to five characters long
		and there will be 3 to 10 characters total even without unit conversion:
			1.1, 116.5/88.0.
		If there are units, that adds three characters per number: 25 °C/20 °C.
		In each of these cases, a line break is needed so that table cells are not too wide;
		even more so when more than one of these things are true.
		]]
	if outputFormat.convertUnits == true then
		brackets = outputFormat.brackets == true and { "(", ")" } or { "", "" }
		if outputFormat.lineBreak == "auto" then
			convertedUnitsSeparator = ( checkForString(values[2]) or decimals ~= "0" or outputFormat.showUnits == true ) and "<br>" or "&nbsp;"
		else
			convertedUnitsSeparator = outputFormat.lineBreak == true and "<br>" or outputFormat.lineBreak == false and "&nbsp;" or error('Value for lineBreak not recognized')
		end
	end
	
	cellContent = values[1] .. highLowSeparator[1] .. values[2] .. convertedUnitsSeparator .. brackets[1] .. convertedUnits[1] .. highLowSeparator[2] .. convertedUnits[2] .. brackets[2]
	
	if tonumber(c) then
		colorCSS = outputFormat.color == true and temperatureCSS(c, format.inputUnit, format.palette, format.inputUnit) or ""
		if tonumber(b) and tonumber(a) then
			local attributeValue = outputFormat.first == format.inputUnit and c or convert(c, format.inputUnit, decimals)
			sortkey = outputFormat.sortable == true and " data-sort-value=\"" .. attributeValue .. "\"" or ""
			titleAttribute = " title=\"Average temperature: " .. attributeValue .. " " .. degree .. outputFormat.first .. "\""
		end
	elseif tonumber(b) then
		colorCSS = ""
	elseif tonumber(a) then
		colorCSS = outputFormat.color == true and temperatureCSS(a, format.inputUnit, format.palette) or ""
	else
		addMessage('Neither a nor b nor c are strings.')
	end
	otherCSS = outputFormat.smallFont == true and "font-size: 85%;" or ""
	if checkForString(colorCSS) or checkForString(otherCSS) then
		styleAttribute = { "style=\"", "\"" }
	end
	
	if checkForString(otherCSS) or checkForString(colorCSS) or checkForString(titleAttribute) or checkForString(sortkey) then
		attributeSeparator = " | "
	end
	cell = "\n| " .. styleAttribute[1] .. colorCSS .. otherCSS .. styleAttribute[2] .. titleAttribute .. sortkey .. attributeSeparator .. cellContent
	return cell
end

--[[
	Replaces hyphens that have a punctuation or space character before them and a number after them,
	making sure that hyphens in "data-sort-type" are not replaced with minuses.
	If Lua had (?<=), a capture would not be necessary. 
]]
local function hyphenToMinus(str)
	return gsub(str, "([%p%s])-(%d)", "%1" .. minus .. "%2")
end

function p.makeRow(frame)
	local format = getFormat(frame.args.input, frame.args.output, frame.args.palette, frame.args.messages)
	makeArrays(frame)
	local output = {}
	if frame.args[1] then
		table.insert(output, "\n|-")
		table.insert(output, "\n! " .. frame.args[1])
		if frame.args[2] then
			table.insert(output, " !! " .. frame.args[2])
		end
	end
	if format.cellFormat then
		outputFormat = format.cellFormat
	end
	-- Assumes that if c is defined, b and a are, and if b is defined, a is.
	if c then
		if not outputFormat then
			outputFormat = outputFormats.high_low_average_F
		end
		for i = 1, format.length do
			table.insert(output, makeCell(outputFormat, a[i], b[i], c[i], format))
		end
	elseif b then
		if not outputFormat then
			outputFormat = outputFormats.high_low_F
		end
		for i = 1, format.length do
			table.insert(output, makeCell(outputFormat, a[i], b[i], nil, format))
		end
	elseif a then
		if not outputFormat then
			outputFormat = outputFormats.average_F
		end
		for i = 1, format.length do
			table.insert(output, makeCell(outputFormat, a[i], nil, nil, format))
		end
	end
	output = table.concat(output)
	output = hyphenToMinus(output)
	return output
end

function p.makeTable(frame)
	local format = getFormat(frame.args.input, frame.args.output, frame.args.palette, frame.args.messages)
	makeArrays(frame)
	local output = { "{| class=\"wikitable center nowrap\"" }
	if format.cellFormat then
		outputFormat = format.cellFormat
	end
	-- Assumes that if c is defined, b and a are, and if b is defined, a is.
	if c then
		for i = 1, format.length do
			if not outputFormat then
				outputFormat = outputFormats.high_low_average_F
			end
			table.insert(output, makeCell(outputFormat, a[i], b[i], c[i], format))
		end
	elseif b then
		for i = 1, format.length do
			if not outputFormat then
				outputFormat = outputFormats.high_low_F
			end
			table.insert(output, makeCell(outputFormat, a[i], b[i], nil, format))
		end
	elseif a then
		for i = 1, format.length do
			if not outputFormat then
				outputFormat = outputFormats.average_F
			end
			table.insert(output, makeCell(outputFormat, a[i], nil, nil, format))
		end
	end
	table.insert(output, "\n|}")
	if format.show then
		table.insert(output, "\n\n<span style=\"color: red; font-size: 80%; line-height: 100%;\">" .. message .. "</span>")
	end
	output = table.concat(output)
	
	output = hyphenToMinus(output)
	
	return output
end



local chart = [[
{{Graph:Chart
|width=600
|height=180
|xAxisTitle=Celsius
|yAxisTitle=__COLOR
|type=line
|x=__XVALUES
|y=__YVALUES
|colors=__COLOR
}}
]]

function p.show(frame)
	-- For testing, return wikitext to show graphs of how the red/green/blue colors
	-- vary with temperature, and a table of the resulting colors.
	local function collection()
		-- Return a table to hold items.
		return {
			n = 0,
			add = function (self, item)
				if item then
					self.n = self.n + 1
					self[self.n] = item
				end
			end,
			join = function (self, sep)
				return table.concat(self, sep)
			end,
		}
	end
	local function make_chart(result, color, xvalues, yvalues)
		result:add('\n')
		result:add(frame:preprocess((chart:gsub('__[A-Z]+', {
			__COLOR = color,
			__XVALUES = xvalues:join(','),
			__YVALUES = yvalues:join(','),
		}))))
	end
	local function with_minus(value)
		if value < 0 then
			return minus .. tostring(-value)
		end
		return tostring(value)
	end
	local args = frame.args
	local first = args[1] or -90
	local last = args[2] or 59
	local palette = p.palettes[args.palette] or p.palettes.cool
	local xvals, reds, greens, blues = collection(), collection(), collection(), collection()
	local wikitext = collection()
	wikitext:add('{| class="wikitable"\n|-\n')
	local columns = 0
	for celsius = first, last do
		local backgroundRGB = {}
		local style = styleAttribute(palette, celsius, backgroundRGB)
		local R = math.floor(backgroundRGB[1])
		local G = math.floor(backgroundRGB[2])
		local B = math.floor(backgroundRGB[3])
		xvals:add(celsius)
		reds:add(R)
		greens:add(G)
		blues:add(B)
		wikitext:add('| ' .. style .. ' | ' .. with_minus(celsius) .. '\n')
		columns = columns + 1
		if columns >= 10 then
			columns = 0
			wikitext:add('|-\n')
		end
	end
	wikitext:add('|}\n')
	make_chart(wikitext, 'Red', xvals, reds)
	make_chart(wikitext, 'Green', xvals, greens)
	make_chart(wikitext, 'Blue', xvals, blues)
	return wikitext:join()
end

return p