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