Module:Timeline

From Soyjak Wiki, the free ensoyclopedia
Jump to navigationJump to search

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