CURRENT MISSION: Conclude the 2026 soyjak.party hack page, aswell as any pages relating to it, such as the Summer 2026 Crisis page.
Update Quotecord with any relevant info. See the blackboard for more info.
Module:Timeline
From Soyjak Wiki, the free ensoyclopedia
Jump to navigationJump to search
Documentation for this module may be created at Module:Timeline/doc
local p = {}
------------------------------------------------------------
-- Utility
------------------------------------------------------------
local function trim(s)
return mw.text.trim(s or '')
end
local function escapePattern(s)
return (s:gsub('([^%w])', '%%%1'))
end
local function splitLines(text)
local lines = {}
text = text:gsub('\r\n', '\n'):gsub('\r', '\n')
for line in (text .. '\n'):gmatch('(.-)\n') do
table.insert(lines, line)
end
return lines
end
------------------------------------------------------------
-- Date handling
------------------------------------------------------------
local function parseDate(value)
local day, month, year =
value:match('^(%d%d?)/(%d%d?)/(%d%d%d%d)$')
if not day then
return nil
end
day = tonumber(day)
month = tonumber(month)
year = tonumber(year)
-- Gregorian date -> integer day count.
--
-- We don't use os.time() because Scribunto's environment and
-- platform timestamp ranges shouldn't determine what dates the
-- timeline can display.
if month <= 2 then
year = year - 1
month = month + 12
end
return
365 * year +
math.floor(year / 4) -
math.floor(year / 100) +
math.floor(year / 400) +
math.floor((153 * (month - 3) + 2) / 5) +
day - 1
end
local function position(date, first, last)
return (date - first) / (last - first) * 100
end
local function percent(n)
return string.format('%.6f%%', n)
end
------------------------------------------------------------
-- Token parsing
------------------------------------------------------------
-- Parses:
--
-- width:1200 height:auto barincrement:25
--
-- into:
--
-- {
-- width = "1200",
-- height = "auto",
-- barincrement = "25"
-- }
local function parseTokens(text)
local result = {}
local matches = {}
-- Find every parameter start: key:
for startPos, key, valueStart in text:gmatch(
'()([%w_-]+):()'
) do
table.insert(matches, {
start = startPos,
key = key:lower(),
valueStart = valueStart
})
end
for i, token in ipairs(matches) do
local valueEnd
if matches[i + 1] then
valueEnd = matches[i + 1].start - 1
else
valueEnd = #text
end
local value = text:sub(
token.valueStart,
valueEnd
)
result[token.key] = trim(value)
end
return result
end
------------------------------------------------------------
-- Colors
------------------------------------------------------------
local namedColors = {
black = '#000000',
white = '#ffffff',
red = '#ff0000',
green = '#008000',
blue = '#0000ff',
yellow = '#ffff00',
gray = '#808080',
grey = '#808080',
orange = '#ffa500',
purple = '#800080',
pink = '#ffc0cb',
brown = '#a52a2a'
}
local function componentTo255(value)
value = tonumber(value) or 0
value = math.max(0, math.min(1, value))
return math.floor(value * 255 + 0.5)
end
local function colorToCSS(value)
value = trim(value)
--------------------------------------------------------
-- rgb(r,g,b)
--------------------------------------------------------
local r, g, b = value:match(
'^rgb%(%s*([%d%.]+)%s*,%s*([%d%.]+)%s*,%s*([%d%.]+)%s*%)$'
)
if r then
return string.format(
'rgb(%d,%d,%d)',
componentTo255(r),
componentTo255(g),
componentTo255(b)
)
end
--------------------------------------------------------
-- gray(x)
--------------------------------------------------------
local gray = value:match(
'^gray%(%s*([%d%.]+)%s*%)$'
)
if gray then
local c = componentTo255(gray)
return string.format(
'rgb(%d,%d,%d)',
c,
c,
c
)
end
--------------------------------------------------------
-- CSS/named color
--------------------------------------------------------
return namedColors[value:lower()] or value
end
------------------------------------------------------------
-- Definitions
------------------------------------------------------------
local function substituteDefinitions(text, definitions)
for name, value in pairs(definitions) do
text = text:gsub(
escapePattern('$' .. name),
value
)
end
return text
end
------------------------------------------------------------
-- Main parser
------------------------------------------------------------
local sectionNames = {
imagesize = true,
plotarea = true,
alignbars = true,
colors = true,
dateformat = true,
period = true,
timeaxis = true,
scalemajor = true,
scaleminor = true,
bardata = true,
plotdata = true,
legend = true,
textdata = true
}
local function parse(text)
local data = {
definitions = {},
colors = {},
colorOrder = {},
bars = {},
plots = {},
texts = {},
imageSize = {},
plotArea = {},
period = {},
scaleMajor = {},
scaleMinor = {},
timeAxis = {},
plotDefaults = {},
legend = nil
}
--------------------------------------------------------
-- First pass: definitions
--------------------------------------------------------
for _, line in ipairs(splitLines(text)) do
local name, value = line:match(
'^%s*Define%s+%$([%w_-]+)%s*=%s*(.-)%s*$'
)
if name then
data.definitions[name] = trim(value)
end
end
text = substituteDefinitions(
text,
data.definitions
)
--------------------------------------------------------
-- Parse sections
--------------------------------------------------------
local currentSection = nil
for _, rawLine in ipairs(splitLines(text)) do
local line = trim(rawLine)
if line ~= '' then
------------------------------------------------
-- Ignore Define now; already processed
------------------------------------------------
if line:match('^Define%s+') then
-- nothing
else
local section, rest = line:match(
'^([%w]+)%s*=%s*(.*)$'
)
if section and sectionNames[section:lower()] then
currentSection = section:lower()
rest = trim(rest)
------------------------------------------------
-- Single-line section content
------------------------------------------------
if rest ~= '' then
if currentSection == 'imagesize' then
data.imageSize = parseTokens(rest)
elseif currentSection == 'plotarea' then
data.plotArea = parseTokens(rest)
elseif currentSection == 'alignbars' then
data.alignBars = rest
elseif currentSection == 'dateformat' then
data.dateFormat = rest
elseif currentSection == 'period' then
data.period = parseTokens(rest)
elseif currentSection == 'timeaxis' then
data.timeAxis = parseTokens(rest)
elseif currentSection == 'scalemajor' then
data.scaleMajor = parseTokens(rest)
elseif currentSection == 'scaleminor' then
data.scaleMinor = parseTokens(rest)
elseif currentSection == 'legend' then
data.legend = parseTokens(rest)
end
end
----------------------------------------------------
-- Colors
----------------------------------------------------
elseif currentSection == 'colors' then
local tokens = parseTokens(line)
if tokens.id and tokens.value then
data.colors[tokens.id] = {
value = colorToCSS(tokens.value),
legend = tokens.legend
}
table.insert(
data.colorOrder,
tokens.id
)
end
----------------------------------------------------
-- BarData
----------------------------------------------------
elseif currentSection == 'bardata' then
local tokens = parseTokens(line)
if tokens.bar then
table.insert(
data.bars,
tokens.bar
)
end
----------------------------------------------------
-- TextData
----------------------------------------------------
elseif currentSection == 'textdata' then
local tokens = parseTokens(line)
if tokens.pos then
data.currentText = tokens
elseif tokens.text and data.currentText then
data.currentText.text = tokens.text:gsub(
'^"(.*)"$',
'%1'
)
table.insert(
data.texts,
data.currentText
)
data.currentText = nil
end
----------------------------------------------------
-- PlotData
----------------------------------------------------
elseif currentSection == 'plotdata' then
-- Lines containing bar: are actual plot
-- entries. Lines without it configure the
-- defaults for following plot entries.
if line:match('^bar:') then
local tokens = parseTokens(line)
if tokens.bar then
table.insert(
data.plots,
tokens
)
end
else
local defaults = parseTokens(line)
for key, value in pairs(defaults) do
data.plotDefaults[key] = value
end
end
end
end
end
end
return data
end
------------------------------------------------------------
-- Grid helpers
------------------------------------------------------------
local function nextMonth(month, year, increment)
increment = increment or 1
month = month + increment
while month > 12 do
month = month - 12
year = year + 1
end
return month, year
end
------------------------------------------------------------
-- Date parsing for scale starts
------------------------------------------------------------
local function parseScaleStart(value, unit)
if not value then
return nil
end
if unit == 'year' and value:match('^%d%d%d%d$') then
return parseDate(
'01/01/' .. value
)
end
return parseDate(value)
end
------------------------------------------------------------
-- Add scale
------------------------------------------------------------
local function addScale(
timeline,
config,
colors,
first,
last,
major
)
if not config.unit then
return
end
local unit = config.unit:lower()
local increment = tonumber(config.increment) or 1
local gridColor = '#ddd'
if config.gridcolor then
gridColor = colors[config.gridcolor] and colors[config.gridcolor].value or colorToCSS(config.gridcolor)
end
--------------------------------------------------------
-- Years
--------------------------------------------------------
if unit == 'year' then
local startYear
if config.start then
startYear = tonumber(config.start)
if not startYear then
local d, m, y = config.start:match(
'^(%d+)/(%d+)/(%d%d%d%d)$'
)
startYear = tonumber(y)
end
end
startYear = startYear or 1970
local year = startYear
while true do
local date = parseDate(
string.format(
'01/01/%04d',
year
)
)
if date > last then
break
end
if date >= first then
local x = position(
date,
first,
last
)
timeline
:tag('div')
:addClass(
major
and 'easy-timeline-grid-major'
or 'easy-timeline-grid-minor'
)
:css('left', percent(x))
:css('background-color', gridColor)
if major then
timeline
:tag('div')
:addClass('easy-timeline-scale-label')
:css('left', percent(x))
:wikitext(tostring(year))
end
end
year = year + increment
end
--------------------------------------------------------
-- Months
--------------------------------------------------------
elseif unit == 'month' then
local start = config.start
if not start then
return
end
local d, month, year = start:match(
'^(%d+)/(%d+)/(%d%d%d%d)$'
)
d = tonumber(d)
month = tonumber(month)
year = tonumber(year)
if not d then
return
end
while true do
local date = parseDate(
string.format(
'%02d/%02d/%04d',
d,
month,
year
)
)
if date > last then
break
end
if date >= first then
local x = position(
date,
first,
last
)
timeline
:tag('div')
:addClass(
major
and 'easy-timeline-grid-major'
or 'easy-timeline-grid-minor'
)
:css(
'left',
percent(
position(
date,
first,
last
)
)
)
:css(
'background-color',
gridColor
)
if major and month == 1 then
timeline
:tag('div')
:addClass('easy-timeline-scale-label')
:css('left', percent(x))
:wikitext(tostring(year))
end
end
month, year =
nextMonth(month, year, increment)
end
elseif unit == 'day' then
local start = config.start
if not start then
return
end
local date = parseDate(start)
if not date then
return
end
while date <= last do
if date >= first then
local x = position(
date,
first,
last
)
timeline
:tag('div')
:addClass(
major
and 'easy-timeline-grid-major'
or 'easy-timeline-grid-minor'
)
:css('left', percent(x))
:css('background-color', gridColor)
end
date = date + increment
end
end
end
------------------------------------------------------------
-- Rendering
------------------------------------------------------------
local function render(data)
local first = parseDate(data.period.from)
local last = parseDate(data.period.till)
if not first or not last then
return '<strong class="error">Timeline has an invalid Period.</strong>'
end
if last <= first then
return '<strong class="error">Timeline period ends before it begins.</strong>'
end
--------------------------------------------------------
-- Dimensions
--------------------------------------------------------
local width =
tonumber(data.imageSize.width) or 1200
local barIncrement =
tonumber(data.imageSize.barincrement) or 25
local plotWidth =
tonumber(data.plotDefaults.width) or 7
local left =
tonumber(data.plotArea.left) or 10
local right =
tonumber(data.plotArea.right) or 80
local top =
tonumber(data.plotArea.top) or 10
local bottom =
tonumber(data.plotArea.bottom) or 50
--------------------------------------------------------
-- Root
--------------------------------------------------------
local root = mw.html.create('div')
:addClass('easy-timeline-wrapper')
local timeline = root
:tag('div')
:addClass('easy-timeline')
:css('width', width .. 'px')
:css(
'height',
(
top +
bottom +
(#data.bars * barIncrement)
) .. 'px'
)
--------------------------------------------------------
-- Plot area
--------------------------------------------------------
local plot = timeline
:tag('div')
:addClass('easy-timeline-plot')
:css('left', left .. 'px')
:css('right', right .. 'px')
:css('top', top .. 'px')
:css('bottom', bottom .. 'px')
--------------------------------------------------------
-- Grid
--------------------------------------------------------
addScale(
plot,
data.scaleMinor,
data.colors,
first,
last,
false
)
addScale(
plot,
data.scaleMajor,
data.colors,
first,
last,
true
)
--------------------------------------------------------
-- Map rows -> index
--------------------------------------------------------
local rowIndex = {}
for index, bar in ipairs(data.bars) do
rowIndex[bar] = index
end
--------------------------------------------------------
-- Plot bars
--------------------------------------------------------
for _, item in ipairs(data.plots) do
local row = rowIndex[item.bar]
local from = parseDate(item.from)
local till = parseDate(item.till)
if row and from and till then
------------------------------------------------
-- Clip to timeline period
------------------------------------------------
local drawFrom = math.max(
from,
first
)
local drawTill = math.min(
till,
last
)
if drawTill >= drawFrom then
local x1 = position(
drawFrom,
first,
last
)
local x2 = position(
drawTill,
first,
last
)
local color = '#999'
if (
item.color and
data.colors[item.color]
) then
color =
data.colors[item.color].value
end
local bar = plot
:tag('div')
:addClass('easy-timeline-bar')
:css(
'top',
(
(row - 1) *
barIncrement +
(
barIncrement -
plotWidth
) / 2
) .. 'px'
)
:css(
'height',
plotWidth .. 'px'
)
:css(
'left',
percent(x1)
)
:css(
'width',
percent(
math.max(
x2 - x1,
0.08
)
)
)
:css(
'background-color',
color
)
------------------------------------------------
-- Text
------------------------------------------------
if item.text and item.text ~= '' then
local label = bar
:tag('span')
:addClass(
'easy-timeline-bar-label'
)
------------------------------------------------
-- shift:(x,y)
------------------------------------------------
local shift =
item.shift or
data.plotDefaults.shift
if shift then
local sx, sy =
shift:match(
'^%((%-?%d+),(%-?%d+)%)$'
)
if sx then
label
:css(
'margin-left',
sx .. 'px'
)
:css(
'margin-top',
sy .. 'px'
)
end
end
label:wikitext(item.text)
end
end
end
end
--------------------------------------------------------
-- Legend
--------------------------------------------------------
if data.legend then
local legend = root
:tag('div')
:addClass('easy-timeline-legend')
for _, id in ipairs(data.colorOrder) do
local color = data.colors[id]
if color.legend then
local entry = legend
:tag('span')
:addClass('easy-timeline-legend-entry')
entry
:tag('span')
:addClass('easy-timeline-legend-swatch')
:css('background-color', color.value)
entry:wikitext(color.legend)
end
end
end
--------------------------------------------------------
-- Text
--------------------------------------------------------
for _, item in ipairs(data.texts) do
if item.pos and item.text then
local x, y = item.pos:match(
'^%((%-?[%d%.]+),(%-?[%d%.]+)%)$'
)
if x and y then
local text = timeline
:tag('div')
:addClass('easy-timeline-text')
:css('left', x .. 'px')
:css('top', y .. 'px')
if item.textcolor then
local color = item.textcolor
if data.colors[color] then
color = data.colors[color].value
else
color = colorToCSS(color)
end
text:css('color', color)
end
if item.fontsize then
local sizes = {
XS = '9px',
S = '11px',
M = '13px',
L = '16px',
XL = '20px'
}
text:css(
'font-size',
sizes[item.fontsize] or '13px'
)
end
text:wikitext(item.text)
end
end
end
return tostring(root)
end
------------------------------------------------------------
-- Public entry point
------------------------------------------------------------
function p.render(frame)
local text = frame.args[1] or ''
if trim(text) == '' then
return '<strong class="error">No timeline data supplied.</strong>'
end
local data = parse(text)
return render(data)
end
return p