Jump to content
Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Documentation for this module may be created at Module:Timeline/doc

-- Module:Timeline
-- CSS-div timeline renderer for the Attu Project wiki.
-- Replaces EasyTimeline (PNG) with inline HTML/CSS that survives MediaWiki's sanitizer.
--
-- Invoke:
--   {{#invoke:Timeline|main
--    |data=List_of_IgRS_Speakers_by_time_as_Speaker/Timeline
--    |period_start=1-1 1 PC
--    |period_end=present
--   }}
--
-- Data subpage may contain:
--   {{TimelineOption|calendar=haracalnde}}  (default; also: deysachni)
--   {{TimelineColor|id|#RRGGBB|Legend Name}}
--   {{TimelineBar|bar_id|label|color_id|start|end[|narrow|mid]}}

local Cal = require("Module:AttuCalendar")

local M = {}

-- Layout constants (mirror EasyTimeline PlotArea / barincrement).
local LABEL_W  = 150   -- px: left label column
local BAR_W    = 1150  -- px: bar area width
local CANVAS_W = LABEL_W + BAR_W
local ROW_H    = 20    -- px: row height (barincrement)
local BAR_H    = 16    -- px: full bar
local MID_H    = 11    -- px: mid-width bar
local NARROW_H = 6     -- px: narrow overlay

-- Nation colour map — muted palette derived from flag hues.
local COLORS = {
    utlia      = "#C0392B",  -- deep red
    akaria     = "#148F77",  -- dark teal
    okrit      = "#1E8449",  -- forest green
    tietero    = "#1A5276",  -- dark blue
    niueyjar   = "#7D3C98",  -- purple
    deysachin  = "#D35400",  -- burnt orange
    nongba     = "#E87C8A",  -- rose
    eee        = "#C9A800",  -- dark amber
    casea      = "#2E86C1",  -- medium sky blue
    faltir     = "#9E7B5A",  -- tan/brown
    kel        = "#2E4057",  -- dark slate
    spyron     = "#7F8C8D",  -- steel gray
    joy        = "#B7770D",  -- dark gold
    larossa    = "#1A237E",  -- navy
    kalam      = "#0E9AA7",  -- cyan-teal
    hapsaw     = "#B03A7A",  -- deep rose/magenta
    steam      = "#6B6B00",  -- olive
    tvaqi      = "#2AA198",  -- solarized cyan (distinct from akaria/kalam)
    walst      = "#7B241C",  -- dark maroon
}

-- Nation display names in legend order.
local NATIONS = {
    { id = "utlia",     name = "Utlia" },
    { id = "akaria",    name = "Akaria" },
    { id = "okrit",     name = "Okrit" },
    { id = "tietero",   name = "Tietero" },
    { id = "niueyjar",  name = "Niueyjar" },
    { id = "deysachin", name = "Deysachin" },
    { id = "nongba",    name = "Nongba" },
    { id = "eee",       name = "eee" },
    { id = "casea",     name = "Casea" },
    { id = "faltir",    name = "Faltir" },
    { id = "kel",       name = "Kelelemi" },
    { id = "spyron",    name = "Spyron" },
    { id = "joy",       name = "Joy" },
    { id = "larossa",   name = "La Rossa" },
    { id = "kalam",     name = "Kalam" },
    { id = "hapsaw",    name = "Hapshaw" },
    { id = "steam",     name = "Steamworks" },
    { id = "tvaqi",     name = "T'vaqi" },
    { id = "walst",     name = "Wälstanland" },
}

-- ---------- date parsers ----------

-- Parse a Deysachni date (year.month.day, year.month, or year) into a
-- fractional coordinate. Deysachni years are an independent system with no
-- fixed offset to PC; the numbers are used as raw coordinates on the axis.
-- "present" returns math.huge so frac_of clamps it to the end of the period.
local function parse_deysachni(s)
    s = mw.text.trim(s)
    if s == "present" then return math.huge end
    local y, mo, d
    y, mo, d = s:match("^(-?%d+)%.(%d+)%.(%d+)$")
    if y then
        y, mo, d = tonumber(y), tonumber(mo), tonumber(d)
    else
        y, mo = s:match("^(-?%d+)%.(%d+)$")
        if y then y, mo, d = tonumber(y), tonumber(mo), 1 end
    end
    if not y then
        local raw = s:match("^(-?%d+)$")
        if raw then y, mo, d = tonumber(raw), 1, 1 end
    end
    if not y then error("unrecognised Deysachni date: " .. tostring(s)) end
    local frac = ((mo - 1) * 30 + (d - 1)) / 360
    if y > 0 then return y + frac else return y - frac end
end

local DATE_PARSERS = {
    haracalnde = function(s) return Cal.parse_date(s) end,
    deysachni  = parse_deysachni,
}

-- ---------- data subpage ----------

local function get_page_content(title)
    local t = mw.title.new(title)
    return t and t:getContent() or nil
end

-- Parse the data subpage. Recognises three template calls:
--
--   {{TimelineOption|calendar=haracalnde}}
--   {{TimelineColor|id|#hex|Legend Name}}
--   {{TimelineBar|bar|label|color|start|end[|narrow]}}
--
-- Returns rows, segs, ex_colors, ex_nations, options.
local function parse_data(content)
    local rows       = {}
    local row_idx    = {}
    local segs       = {}
    local ex_colors  = {}   -- {id → hex} — page-defined, override hardcoded
    local ex_nations = {}   -- [{id, name}] in declaration order
    local options    = { calendar = "haracalnde" }

    for line in content:gmatch("[^\n]+") do
        -- TimelineOption
        local opt_body = line:match("^%s*{{TimelineOption|(.-)}}%s*$")
        if opt_body then
            for kv in (opt_body .. "|"):gmatch("([^|]+)|") do
                local k, v = kv:match("^([^=]+)=(.*)$")
                if k then
                    options[mw.text.trim(k):lower()] = mw.text.trim(v):lower()
                end
            end
        end

        -- TimelineColor
        local tc_id, tc_hex, tc_name =
            line:match("^%s*{{TimelineColor|([^|]+)|([^|]+)|([^|}]+)}}%s*$")
        if tc_id then
            tc_id   = mw.text.trim(tc_id):lower()
            tc_hex  = mw.text.trim(tc_hex)
            tc_name = mw.text.trim(tc_name)
            if not ex_colors[tc_id] then
                ex_colors[tc_id] = tc_hex
                table.insert(ex_nations, { id = tc_id, name = tc_name })
            end
        end

        -- TimelineBar: 6-arg form first, then 5-arg.
        local bar, label, color, start_s, end_s, narrow =
            line:match("^%s*{{TimelineBar|([^|]+)|([^|]+)|([^|]+)|([^|]+)|([^|}]+)|([^|}]*)}}%s*$")
        if not bar then
            bar, label, color, start_s, end_s =
                line:match("^%s*{{TimelineBar|([^|]+)|([^|]+)|([^|]+)|([^|]+)|([^|}]+)}}%s*$")
            narrow = ""
        end
        if bar then
            bar     = mw.text.trim(bar)
            label   = mw.text.trim(label)
            color   = mw.text.trim(color):lower()
            start_s = mw.text.trim(start_s)
            end_s   = mw.text.trim(end_s)
            narrow  = mw.text.trim(narrow or ""):lower()
            if not row_idx[bar] then
                table.insert(rows, { id = bar, label = label })
                row_idx[bar] = #rows
            end
            if not segs[bar] then segs[bar] = {} end
            local bar_type = "full"
            if narrow == "narrow" then bar_type = "narrow"
            elseif narrow == "mid"    then bar_type = "mid"
            end
            table.insert(segs[bar], {
                color    = color,
                start    = start_s,
                stop     = end_s,
                bar_type = bar_type,
            })
        end
    end
    return rows, segs, ex_colors, ex_nations, options
end

-- ---------- pixel helpers ----------

local function frac_to_x(frac)
    return LABEL_W + math.floor(frac * BAR_W)
end

local function frac_to_w(f1, f2)
    return math.max(2, math.floor((f2 - f1) * BAR_W))
end

-- ---------- renderers ----------

local function render_segs(seg_list, row_top, frac_of, colors)
    -- Render full bars first, then mid, then narrow, so overlays draw on top.
    local full   = {}
    local mid    = {}
    local narrow = {}
    for _, seg in ipairs(seg_list) do
        local ok,  f1 = pcall(frac_of, seg.start)
        local ok2, f2 = pcall(frac_of, seg.stop)
        if ok and ok2 and f2 > f1 then
            f1 = math.max(0, math.min(1, f1))
            f2 = math.max(0, math.min(1, f2))
            local x  = frac_to_x(f1)
            local w  = frac_to_w(f1, f2)
            local bg = colors[seg.color] or "#CCCCCC"
            local h, top_off
            if seg.bar_type == "narrow" then
                h       = NARROW_H
                top_off = row_top + math.floor((ROW_H - NARROW_H) / 2)
            elseif seg.bar_type == "mid" then
                h       = MID_H
                top_off = row_top + math.floor((ROW_H - MID_H) / 2)
            else
                h       = BAR_H
                top_off = row_top + 2
            end
            local div = string.format(
                '<div style="position:absolute;left:%dpx;top:%dpx;width:%dpx;height:%dpx;background:%s;"></div>',
                x, top_off, w, h, bg
            )
            if seg.bar_type == "narrow" then narrow[#narrow + 1] = div
            elseif seg.bar_type == "mid" then mid[#mid + 1]     = div
            else                               full[#full + 1]   = div
            end
        end
    end
    return table.concat(full) .. table.concat(mid) .. table.concat(narrow)
end

local function render_label(label, row_top)
    return string.format(
        '<div style="position:absolute;left:0;top:%dpx;width:%dpx;height:%dpx;'
        .. 'overflow:hidden;white-space:nowrap;font-size:11px;line-height:%dpx;">%s</div>',
        row_top, LABEL_W - 4, ROW_H, ROW_H, label
    )
end

-- Pick a round major-tick interval that gives roughly 8-15 labels across the span.
local function nice_interval(span)
    if span < 100 then return 5 end
    local target = span / 15
    local nices  = {10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000}
    for _, v in ipairs(nices) do
        if v >= target then return v end
    end
    return nices[#nices]
end

-- Render year axis ticks and major labels.
-- Returns the HTML string and the additional height consumed by the axis.
local function render_axis(ps_num, pe_num, bars_h)
    local out      = {}
    local axis_top = bars_h + 4
    local span     = pe_num - ps_num
    if span <= 0 then return "", 28 end

    local major_int   = nice_interval(span)
    local minor_int   = math.floor(major_int / 5)
    local px_per_unit = BAR_W / span
    local draw_minor  = minor_int >= 1 and (px_per_unit * minor_int) >= 4

    -- Minor ticks (skip positions covered by a major tick)
    if draw_minor then
        local m0 = math.ceil(ps_num / minor_int) * minor_int
        local y  = m0
        while y <= pe_num do
            if y % major_int ~= 0 then
                local frac = (y - ps_num) / span
                if frac >= 0 and frac <= 1 then
                    out[#out + 1] = string.format(
                        '<div style="position:absolute;left:%dpx;top:%dpx;width:1px;height:3px;background:#555;"></div>',
                        frac_to_x(frac), axis_top
                    )
                end
            end
            y = y + minor_int
        end
    end

    -- Major ticks and labels
    local y0 = math.ceil(ps_num / major_int) * major_int
    local y  = y0
    while y <= pe_num do
        local frac = (y - ps_num) / span
        if frac >= 0 and frac <= 1 then
            local x = frac_to_x(frac)
            out[#out + 1] = string.format(
                '<div style="position:absolute;left:%dpx;top:%dpx;width:1px;height:6px;background:#555;"></div>',
                x, axis_top
            )
            out[#out + 1] = string.format(
                '<div style="position:absolute;left:%dpx;top:%dpx;width:40px;'
                .. 'font-size:10px;text-align:center;white-space:nowrap;">%d</div>',
                x - 20, axis_top + 8, y
            )
        end
        y = y + major_int
    end

    return table.concat(out), 28  -- axis consumes 28px below bar area
end

local function render_legend(used_colors, colors, nations)
    local items = {}
    for _, n in ipairs(nations) do
        if used_colors[n.id] then
            items[#items + 1] = string.format(
                '<span style="display:inline-block;width:12px;height:12px;'
                .. 'background:%s;margin-right:4px;vertical-align:middle;"></span>%s',
                colors[n.id] or "#ccc", n.name
            )
        end
    end

    local cols    = 4
    local col_w   = 180
    local gap     = 8
    local slot_w  = col_w + gap
    local n_rows  = math.ceil(#items / cols)
    local leg_h   = n_rows * 20
    local leg_w   = cols * slot_w - gap
    local out     = {
        string.format(
            '<div style="position:relative;width:%dpx;height:%dpx;margin-top:8px;">',
            leg_w, leg_h
        )
    }
    for i, item in ipairs(items) do
        local col = (i - 1) % cols
        local row = math.floor((i - 1) / cols)
        out[#out + 1] = string.format(
            '<div style="position:absolute;left:%dpx;top:%dpx;width:%dpx;'
            .. 'font-size:11px;white-space:nowrap;">%s</div>',
            col * slot_w, row * 20, col_w, item
        )
    end
    out[#out + 1] = '</div>'
    return table.concat(out)
end

-- ---------- main ----------

function M.main(frame)
    local parent = frame:getParent()
    local args = (parent and next(parent.args)) and parent.args or frame.args

    local data_page    = mw.text.trim(args.data         or "")
    local period_start = mw.text.trim(args.period_start or "1-1 1 PC")
    local period_end   = mw.text.trim(args.period_end   or "present")

    if data_page == "" then
        return '<span class="error">Timeline: missing |data= parameter</span>'
    end

    local content = get_page_content(data_page)
    if not content then
        return '<span class="error">Timeline: data page not found: '
            .. mw.text.nowiki(data_page) .. '</span>'
    end

    local rows, segs, ex_colors, ex_nations, options = parse_data(content)
    if #rows == 0 then
        return '<span class="error">Timeline: no TimelineBar entries found</span>'
    end

    -- Build effective color table: page-defined colors extend (and override) hardcoded.
    local colors = {}
    for k, v in pairs(COLORS) do colors[k] = v end
    for k, v in pairs(ex_colors) do colors[k] = v end

    -- Build legend list: hardcoded nations first, then page-defined in declaration order.
    local nations = {}
    local known   = {}
    for _, n in ipairs(NATIONS) do
        known[n.id] = true
        table.insert(nations, n)
    end
    for _, n in ipairs(ex_nations) do
        if not known[n.id] then
            table.insert(nations, n)
        end
    end

    -- Select date parser based on calendar option.
    local parse_date = DATE_PARSERS[options.calendar]
    if not parse_date then
        return '<span class="error">Timeline: unknown calendar "'
            .. mw.text.nowiki(options.calendar) .. '"</span>'
    end

    -- Parse period bounds using the same calendar as the data.
    local ok1, ps_num = pcall(parse_date, period_start)
    local ok2, pe_num = pcall(parse_date, period_end)
    if not (ok1 and ok2) then
        return '<span class="error">Timeline: invalid period_start or period_end</span>'
    end

    -- frac_of: date string → [0,1] fraction within the period.
    local function frac_of(date_str)
        local d    = parse_date(date_str)
        local span = pe_num - ps_num
        if span == 0 then return 0 end
        return (d - ps_num) / span
    end

    -- Collect used colours for the legend.
    local used_colors = {}
    for _, row in ipairs(rows) do
        for _, seg in ipairs(segs[row.id] or {}) do
            used_colors[seg.color] = true
        end
    end

    -- Build the bar canvas.
    local bars_h  = #rows * ROW_H
    local stripes = {}
    local bar_parts = {}

    for i, row in ipairs(rows) do
        local top = (i - 1) * ROW_H
        if i % 2 == 0 then
            stripes[#stripes + 1] = string.format(
                '<div style="position:absolute;left:0;top:%dpx;width:%dpx;height:%dpx;background:rgba(80,130,200,0.18);"></div>',
                top, CANVAS_W, ROW_H
            )
        end
        bar_parts[#bar_parts + 1] = render_label(row.label, top)
        bar_parts[#bar_parts + 1] = render_segs(segs[row.id] or {}, top, frac_of, colors)
    end

    local parts = stripes
    for _, v in ipairs(bar_parts) do parts[#parts + 1] = v end

    -- Vertical line at the TT/PC boundary (year 1.0 in fractional space).
    -- Only meaningful for the Haracalnde calendar.
    local span = pe_num - ps_num
    if options.calendar == "haracalnde" and span > 0 and ps_num <= 1 and pe_num >= 1 then
        local boundary_x = frac_to_x((1.0 - ps_num) / span)
        parts[#parts + 1] = string.format(
            '<div style="position:absolute;left:%dpx;top:0;width:2px;height:%dpx;background:#2C3E50;"></div>',
            boundary_x, bars_h
        )
    end

    local axis_html, axis_extra = render_axis(ps_num, pe_num, bars_h)
    parts[#parts + 1] = axis_html

    local total_h = bars_h + axis_extra
    local canvas = string.format(
        '<div style="position:relative;width:%dpx;height:%dpx;font-family:sans-serif;font-size:12px;">',
        CANVAS_W, total_h
    ) .. table.concat(parts) .. '</div>'

    return '<div style="overflow-x:auto;padding-bottom:16px;">' .. canvas .. render_legend(used_colors, colors, nations) .. '</div>'
        .. '[[Category:Pages with timelines]]'
end

return M