Module:Timeline
From Soyjak Wiki, the free ensoyclopedia
Jump to navigationJump to search
| This module is rated as ready for general use. It is considered stable and may be used wherever appropriate. Improvements should be developed and tested in a sandbox before changing the live module. |
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 daysInMonth(month, year)
local lengths = {
31, 28, 31, 30, 31, 30,
31, 31, 30, 31, 30, 31
}
if (
year % 400 == 0 or
(year % 4 == 0 and year % 100 ~= 0)
) then
lengths[2] = 29
end
return lengths[month]
end
local function dateParts(value, format)
value = trim(value)
format = trim(format):lower()
if format == '' then
format = 'yyyy'
end
if format == 'yyyy' then
local year = value:match('^(%-?%d+)$')
if not year then
return nil
end
return 1, 1, tonumber(year)
end
local a, b, year =
value:match('^(%d%d?)/(%d%d?)/(%-?%d+)$')
if not a then
return nil
end
a = tonumber(a)
b = tonumber(b)
year = tonumber(year)
local day
local month
if format == 'mm/dd/yyyy' then
month = a
day = b
elseif format == 'dd/mm/yyyy' then
day = a
month = b
else
return nil
end
if month < 1 or month > 12 then
return nil
end
local maxDay = daysInMonth(month, year)
if not maxDay or day < 1 or day > maxDay then
return nil
end
return day, month, year
end
local function parseDate(value, format)
local day, month, year = dateParts(value, format)
if not day then
return nil
end
-- 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 dateString(day, month, year, format)
format = trim(format):lower()
if format == '' or format == 'yyyy' then
return tostring(year)
elseif format == 'mm/dd/yyyy' then
return string.format(
'%02d/%02d/%04d',
month,
day,
year
)
elseif format == 'dd/mm/yyyy' then
return string.format(
'%02d/%02d/%04d',
day,
month,
year
)
end
return nil
end
local function position(date, first, last, reverse)
local pos = (date - first) / (last - first) * 100
if reverse then
return 100 - pos
end
return pos
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
------------------------------------------------------------
-- Add scale
------------------------------------------------------------
local function addScale(
timeline,
config,
colors,
first,
last,
major,
dateFormat,
orientation,
reverse
)
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
if colors[config.gridcolor] then
gridColor = colors[config.gridcolor].value
else
gridColor = colorToCSS(config.gridcolor)
end
end
local function addLine(date, label)
local pos = position(
date,
first,
last,
reverse
)
local line = timeline
:tag('div')
:addClass(
major
and 'easy-timeline-grid-major'
or 'easy-timeline-grid-minor'
)
:css('background-color', gridColor)
if orientation == 'vertical' then
line:css('top', percent(pos))
else
line:css('left', percent(pos))
end
if major and label then
local scaleLabel = timeline
:tag('div')
:addClass('easy-timeline-scale-label')
if orientation == 'vertical' then
scaleLabel:css('top', percent(pos))
else
scaleLabel:css('left', percent(pos))
end
scaleLabel:wikitext(tostring(label))
end
end
--------------------------------------------------------
-- Years
--------------------------------------------------------
if unit == 'year' then
local year
if config.start then
local _, _, startYear =
dateParts(config.start, dateFormat)
year = startYear
end
year = year or 1970
while true do
local value = dateString(1, 1, year, dateFormat)
local date = parseDate(value, dateFormat)
if not date or date > last then
break
end
if date >= first then
addLine(date, year)
end
year = year + increment
end
--------------------------------------------------------
-- Months
--------------------------------------------------------
elseif unit == 'month' then
if not config.start then
return
end
local day, month, year = dateParts(config.start, dateFormat)
if not day then
return
end
while true do
local value = dateString(day, month, year, dateFormat)
local date = parseDate(value, dateFormat)
if not date or date > last then
break
end
if date >= first then
addLine(
date,
major and
month == 1 and
year or
nil
)
end
month, year =
nextMonth(month, year, increment)
local maxDay = daysInMonth(month, year)
if day > maxDay then
day = maxDay
end
end
elseif unit == 'day' then
if not config.start then
return
end
local date = parseDate(config.start,dateFormat)
if not date then
return
end
while date <= last do
if date >= first then
addLine(date)
end
date = date + increment
end
end
end
------------------------------------------------------------
-- Rendering
------------------------------------------------------------
local function render(data)
local dateFormat = (data.dateFormat or 'dd/mm/yyyy'):lower()
local first = parseDate(data.period.from, dateFormat)
local last = parseDate(data.period.till, dateFormat)
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
--------------------------------------------------------
-- TimeAxis
--------------------------------------------------------
local orientation = (data.timeAxis.orientation or 'horizontal'):lower()
if orientation == 'hor' then
orientation = 'horizontal'
elseif orientation == 'ver' then
orientation = 'vertical'
end
local reverse = (data.timeAxis.order or ''):lower() == 'reverse'
--------------------------------------------------------
-- 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
local height =
tonumber(data.imageSize.height)
if not height then
if orientation == 'horizontal' then
height =
top +
bottom +
(#data.bars * barIncrement)
else
height = 600
end
end
--------------------------------------------------------
-- Root
--------------------------------------------------------
local root = mw.html.create('div')
:addClass('easy-timeline-wrapper')
local timeline = root
:tag('div')
:addClass('easy-timeline')
:addClass('easy-timeline-' .. orientation)
:css('width', width .. 'px')
:css('height', height .. '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,
dateFormat,
orientation,
reverse
)
addScale(
plot,
data.scaleMajor,
data.colors,
first,
last,
true,
dateFormat,
orientation,
reverse
)
--------------------------------------------------------
-- Map rows -> index
--------------------------------------------------------
local rowIndex = {}
for index, bar in ipairs(data.bars) do
rowIndex[bar] = index
end
--------------------------------------------------------
-- AlignBars
--------------------------------------------------------
local alignBars =
(data.alignBars or 'early'):lower()
local crossSize
if orientation == 'vertical' then
crossSize = width - left - right
else
crossSize = height - top - bottom
end
local function rowPosition(row)
if alignBars == 'justify' and #data.bars > 1 then
return
(row - 1) *
((crossSize - plotWidth) / (#data.bars - 1))
elseif alignBars == 'late' then
return
crossSize -
(#data.bars * barIncrement) +
((row - 1) * barIncrement) +
(barIncrement - plotWidth) / 2
else
return
((row - 1) * barIncrement) +
(barIncrement - plotWidth) / 2
end
end
--------------------------------------------------------
-- Plot bars
--------------------------------------------------------
for _, item in ipairs(data.plots) do
local row = rowIndex[item.bar]
local from = parseDate(item.from, dateFormat)
local till = parseDate(item.till, dateFormat)
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,
reverse
)
local x2 = position(
drawTill,
first,
last,
reverse
)
local startPos = math.min(x1, x2)
local length =
math.max(
math.abs(x2 - x1),
0.08
)
local color = '#999'
if item.color then
if data.colors[item.color] then
color =
data.colors[item.color].value
else
color = colorToCSS(item.color)
end
end
local bar = plot
:tag('div')
:addClass('easy-timeline-bar')
:css(
'background-color',
color
)
if orientation == 'vertical' then
bar
:css(
'left',
rowPosition(row) .. 'px'
)
:css(
'width',
plotWidth .. 'px'
)
:css(
'top',
percent(startPos)
)
:css(
'height',
percent(length)
)
else
bar
:css(
'top',
rowPosition(row) .. 'px'
)
:css(
'height',
plotWidth .. 'px'
)
:css(
'left',
percent(startPos)
)
:css(
'width',
percent(length)
)
end
------------------------------------------------
-- 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 = timeline
:tag('div')
:addClass('easy-timeline-legend')
local legendPosition =
(data.legend.position or 'bottom'):lower()
local columns = tonumber(data.legend.columns) or 1
legend:css(
'grid-template-columns',
'repeat(' ..
columns ..
', max-content)'
)
if data.legend.left then
legend:css(
'left',
data.legend.left .. 'px'
)
elseif legendPosition == 'right' then
legend:css('right', '0px')
else
legend:css('left', left .. 'px')
end
if legendPosition == 'top' then
legend:css('top', '0px')
elseif legendPosition == 'right' then
legend:css('top', top .. 'px')
end
if data.legend.columnwidth then
legend:css(
'column-gap',
data.legend.columnwidth .. 'px'
)
end
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 mw.getCurrentFrame():extensionTag(
'templatestyles',
'',
{ src = 'Module:Timeline/styles.css' }
) .. 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