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.

Module:Timeline: Difference between revisions

From Attu Project
Content deleted Content added
load_modules: sync
 
load_modules: sync
 
(One intermediate revision by the same user not shown)
Line 9: Line 9:
-- |period_end=present
-- |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 Cal = require("Module:AttuCalendar")
Line 19: Line 24:
local CANVAS_W = LABEL_W + BAR_W
local CANVAS_W = LABEL_W + BAR_W
local ROW_H = 20 -- px: row height (barincrement)
local ROW_H = 20 -- px: row height (barincrement)
local BAR_H = 16 -- px: full bar (ROW_H - 2*2)
local BAR_H = 16 -- px: full bar
local NARROW_H = 6 -- px: narrow overlay height
local MID_H = 11 -- px: mid-width bar
local NARROW_H = 6 -- px: narrow overlay


-- Nation colour map — muted palette derived from flag hues.
-- Nation colour map — muted palette derived from flag hues.
Line 66: Line 72:
{ id = "tvaqi", name = "T'vaqi" },
{ id = "tvaqi", name = "T'vaqi" },
{ id = "walst", name = "Wälstanland" },
{ 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,
}
}


Line 75: Line 112:
end
end


-- Parse the data subpage. Recognises three template calls:
-- Parse {{TimelineBar|bar|label|color|start|end[|narrow]}} lines from raw wikitext.
--
-- Returns:
-- {{TimelineOption|calendar=haracalnde}}
-- rows — list of {id, label} in document order (unique by bar id)
-- {{TimelineColor|id|#hex|Legend Name}}
-- segs — {bar_id → list of {color, start, stop, narrow}}
-- {{TimelineBar|bar|label|color|start|end[|narrow]}}
--
-- Returns rows, segs, ex_colors, ex_nations, options.
local function parse_data(content)
local function parse_data(content)
local rows = {}
local rows = {}
local row_idx = {}
local row_idx = {}
local segs = {}
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
for line in content:gmatch("[^\n]+") do
-- Try 6-arg form first, then 5-arg.
-- 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 =
local bar, label, color, start_s, end_s, narrow =
line:match("^%s*{{TimelineBar|([^|]+)|([^|]+)|([^|]+)|([^|]+)|([^|}]+)|([^|}]*)}}%s*$")
line:match("^%s*{{TimelineBar|([^|]+)|([^|]+)|([^|]+)|([^|]+)|([^|}]+)|([^|}]*)}}%s*$")
Line 105: Line 172:
end
end
if not segs[bar] then segs[bar] = {} 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], {
table.insert(segs[bar], {
color = color,
color = color,
start = start_s,
start = start_s,
stop = end_s,
stop = end_s,
narrow = (narrow == "narrow"),
bar_type = bar_type,
})
})
end
end
end
end
return rows, segs
return rows, segs, ex_colors, ex_nations, options
end
end


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


-- Convert a fractional position [0,1] to absolute x in the bar area.
local function frac_to_x(frac)
local function frac_to_x(frac)
return LABEL_W + math.floor(frac * BAR_W)
return LABEL_W + math.floor(frac * BAR_W)
end
end


-- Minimum 2px bar width to keep short terms visible.
local function frac_to_w(f1, f2)
local function frac_to_w(f1, f2)
return math.max(2, math.floor((f2 - f1) * BAR_W))
return math.max(2, math.floor((f2 - f1) * BAR_W))
end

-- Convert a date string to a fraction of [ps_num, pe_num] without re-parsing bounds.
local function frac_of(date_str, ps_num, pe_num)
local d = Cal.parse_date(date_str)
local span = pe_num - ps_num
if span == 0 then return 0 end
return (d - ps_num) / span
end
end


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


local function render_segs(seg_list, row_top, ps_num, pe_num)
local function render_segs(seg_list, row_top, frac_of, colors)
-- Render full bars before narrow bars so narrow overlays draw on top.
-- Render full bars first, then mid, then narrow, so overlays draw on top.
local full = {}
local full = {}
local mid = {}
local narrow = {}
local narrow = {}
for _, seg in ipairs(seg_list) do
for _, seg in ipairs(seg_list) do
local ok, f1 = pcall(frac_of, seg.start, ps_num, pe_num)
local ok, f1 = pcall(frac_of, seg.start)
local ok2, f2 = pcall(frac_of, seg.stop, ps_num, pe_num)
local ok2, f2 = pcall(frac_of, seg.stop)
if ok and ok2 and f2 > f1 then
if ok and ok2 and f2 > f1 then
f1 = math.max(0, math.min(1, f1))
f1 = math.max(0, math.min(1, f1))
f2 = math.max(0, math.min(1, f2))
f2 = math.max(0, math.min(1, f2))
local x = frac_to_x(f1)
local x = frac_to_x(f1)
local w = frac_to_w(f1, f2)
local w = frac_to_w(f1, f2)
local bg = COLORS[seg.color] or "#CCCCCC"
local bg = colors[seg.color] or "#CCCCCC"
local h, top_off
local h, top_off
if seg.narrow then
if seg.bar_type == "narrow" then
h = NARROW_H
h = NARROW_H
top_off = row_top + math.floor((ROW_H - NARROW_H) / 2)
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
else
h = BAR_H
h = BAR_H
Line 163: Line 228:
x, top_off, w, h, bg
x, top_off, w, h, bg
)
)
if seg.narrow then narrow[#narrow + 1] = div
if seg.bar_type == "narrow" then narrow[#narrow + 1] = div
else full[#full + 1] = div
elseif seg.bar_type == "mid" then mid[#mid + 1] = div
else full[#full + 1] = div
end
end
end
end
end
end
return table.concat(full) .. table.concat(narrow)
return table.concat(full) .. table.concat(mid) .. table.concat(narrow)
end
end


Line 177: Line 243:
row_top, LABEL_W - 4, ROW_H, ROW_H, label
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
end


Line 184: Line 261:
local out = {}
local out = {}
local axis_top = bars_h + 4
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)
-- Draw from the first whole PC year inside the period to the last.
local y_first = math.ceil(ps_num)
local minor_int = math.floor(major_int / 5)
local y_last = math.floor(pe_num)
local px_per_unit = BAR_W / span
local span = pe_num - ps_num
local draw_minor = minor_int >= 1 and (px_per_unit * minor_int) >= 4


-- Minor ticks (skip positions covered by a major tick)
for y = y_first, y_last do
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
local frac = (y - ps_num) / span
if frac >= 0 and frac <= 1 then
if frac >= 0 and frac <= 1 then
local x = frac_to_x(frac)
local x = frac_to_x(frac)
local is_major = (y % 5 == 0)
local tick_h = is_major and 6 or 3

-- Tick mark
out[#out + 1] = string.format(
out[#out + 1] = string.format(
'<div style="position:absolute;left:%dpx;top:%dpx;width:1px;height:%dpx;background:#555;"></div>',
'<div style="position:absolute;left:%dpx;top:%dpx;width:1px;height:6px;background:#555;"></div>',
x, axis_top, tick_h
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
)
)

-- Year label (major ticks only); centred by subtracting half of a 40px box
if is_major then
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
end
end
y = y + major_int
end
end


Line 217: Line 310:
end
end


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


local cols = 4
local cols = 4
local col_w = 180 -- content width per column
local col_w = 180
local gap = 8 -- gap between columns
local gap = 8
local slot_w = col_w + gap
local slot_w = col_w + gap
local n_rows = math.ceil(#items / cols)
local n_rows = math.ceil(#items / cols)
Line 275: Line 368:
end
end


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


-- Build effective color table: page-defined colors extend (and override) hardcoded.
-- Parse period bounds once.
local ok1, ps_num = pcall(Cal.parse_date, period_start)
local colors = {}
for k, v in pairs(COLORS) do colors[k] = v end
local ok2, pe_num = pcall(Cal.parse_date, period_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
if not (ok1 and ok2) then
return '<span class="error">Timeline: invalid period_start or period_end</span>'
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
end


Line 296: Line 422:


-- Build the bar canvas.
-- Build the bar canvas.
local n_rows = #rows
local bars_h = #rows * ROW_H
local bars_h = n_rows * ROW_H
-- Stripes rendered first so bars and labels draw on top.
local stripes = {}
local stripes = {}
local bar_parts = {}
local bar_parts = {}
Line 304: Line 428:
for i, row in ipairs(rows) do
for i, row in ipairs(rows) do
local top = (i - 1) * ROW_H
local top = (i - 1) * ROW_H
-- Alternating light stripe; even rows (0-indexed) get shading.
if i % 2 == 0 then
if i % 2 == 0 then
stripes[#stripes + 1] = string.format(
stripes[#stripes + 1] = string.format(
Line 312: Line 435:
end
end
bar_parts[#bar_parts + 1] = render_label(row.label, top)
bar_parts[#bar_parts + 1] = render_label(row.label, top)
bar_parts[#bar_parts + 1] = render_segs(segs[row.id] or {}, top, ps_num, pe_num)
bar_parts[#bar_parts + 1] = render_segs(segs[row.id] or {}, top, frac_of, colors)
end
end


Line 318: Line 441:
for _, v in ipairs(bar_parts) do parts[#parts + 1] = v end
for _, v in ipairs(bar_parts) do parts[#parts + 1] = v end


-- Vertical line at the TT/PC boundary (start of 1 PC = fractional year 1.0).
-- Vertical line at the TT/PC boundary (year 1.0 in fractional space).
-- Only meaningful for the Haracalnde calendar.
-- Note: parse_date maps TT→negative, PC→positive with no year 0; a chart
-- spanning both eras has ~1 phantom unit of gap between -1 and 1 in pixel space.
local span = pe_num - ps_num
local span = pe_num - ps_num
if span > 0 and ps_num <= 1 and pe_num >= 1 then
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)
local boundary_x = frac_to_x((1.0 - ps_num) / span)
parts[#parts + 1] = string.format(
parts[#parts + 1] = string.format(
Line 339: Line 461:
) .. table.concat(parts) .. '</div>'
) .. table.concat(parts) .. '</div>'


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

Latest revision as of 19:02, 31 May 2026

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