Module:Timeline: Difference between revisions
From Attu Project
More actions
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 |
local BAR_H = 16 -- px: full bar |
||
local |
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 |
||
-- |
-- 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, |
||
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, |
local function render_segs(seg_list, row_top, frac_of, colors) |
||
-- Render full bars |
-- 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 |
local ok, f1 = pcall(frac_of, seg.start) |
||
local ok2, f2 = pcall(frac_of, seg.stop |
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 |
local x = frac_to_x(f1) |
||
local w |
local w = frac_to_w(f1, f2) |
||
local bg |
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 |
||
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 |
local minor_int = math.floor(major_int / 5) |
||
local |
local px_per_unit = BAR_W / span |
||
local |
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 |
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: |
'<div style="position:absolute;left:%dpx;top:%dpx;width:1px;height:6px;background:#555;"></div>', |
||
x, axis_top |
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( |
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 |
|||
) |
) |
||
end |
end |
||
| Line 230: | Line 323: | ||
local cols = 4 |
local cols = 4 |
||
local col_w = 180 |
local col_w = 180 |
||
local gap = 8 |
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 |
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 |
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, |
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 ( |
-- 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