跳转到内容

模組:Date timeline

本页使用了标题或全文手工转换
维基百科,自由的百科全书

local getArgs = require('Module:Arguments').getArgs
local compressSparseArray = require('Module:TableTools').compressSparseArray
local p = {}

-- Note for translators of this module:
-- This module depends on [[:Template:period color]], [[:template:period start]], and [[:template:period end]].
-- Those templates must be implemented on the wiki. If the names are changed, they need to be changed here:
local periodColor = "period color"
local periodStart = "period start"
local periodEnd = "period end"

-- =================
-- UTILITY FUNCTIONS
-- =================

-- Function to convert a date string to decimal year
-- Parameters:
--   dateStr = string in format "YYYY-MM-DD" or "YYYY/MM/DD"
-- Returns:
--   decimal year (e.g., "2023-07-15" -> 2023.54) or nil if not a valid date
local function dateToDecimalYear(dateStr)
    if not dateStr or dateStr == "" then
        return nil
    end
    
    -- Parse the date string
    local year, month, day = dateStr:match("^(%d+)%-(%d+)%-(%d+)$")
    if not year then
        -- Try alternative format YYYY/MM/DD
        year, month, day = dateStr:match("^(%d+)/(%d+)/(%d+)$")
    end
    
    -- If not a date format, return nil
    if not year then
        return nil
    end
    
    -- Convert to numbers
    year, month, day = tonumber(year), tonumber(month), tonumber(day)
    
    -- Validate date components
    if not year or not month or not day or
       month < 1 or month > 12 or
       day < 1 or day > 31 then
        return nil
    end
    
    -- Calculate days in year (account for leap years)
    local daysInYear = 365
    if year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) then
        daysInYear = 366
    end
    
    -- Days in each month
    local daysInMonth = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
    if daysInYear == 366 then
        daysInMonth[2] = 29
    end
    
    -- Calculate day of year
    local dayOfYear = day
    for i = 1, month - 1 do
        dayOfYear = dayOfYear + daysInMonth[i]
    end
    
    -- Calculate decimal year
    return year + (dayOfYear - 1) / daysInYear
end

-- Function to format a decimal year back to a date string
-- Parameters:
--   decimalYear = year with decimal component (e.g., 2023.54)
--   format = output format string ("short", "medium", "full", "chinese", or "year")
local function formatDecimalYear(decimalYear, format)
    if not decimalYear then return nil end

    -- Extract year component
    local year = math.floor(decimalYear)

    -- If format is "year" or not in date mode, just return the year
    if format == "year" then
        if year < 0 then
            return "&minus;" .. math.abs(year)
        else
            return tostring(year)
        end
    end

    -- Extract decimal portion representing day of year
    local dayOfYear = math.floor((decimalYear - year)
        * (year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) and 366 or 365)
    ) + 1

    -- Convert day of year to month and day
    local daysInMonth = {31,28,31,30,31,30,31,31,30,31,30,31}
    if year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) then
        daysInMonth[2] = 29
    end
    local month = 1
    while dayOfYear > daysInMonth[month] do
        dayOfYear = dayOfYear - daysInMonth[month]
        month = month + 1
    end

    -- Format according to requested style
    if format == "short" then
        return string.format("%d-%02d-%02d", year, month, dayOfYear)
    elseif format == "medium" then
        local mnames = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}
        return string.format("%s %d, %d", mnames[month], dayOfYear, year)
    elseif format == "full" then
        local mnames = {"January","February","March","April","May","June",
                        "July","August","September","October","November","December"}
        return string.format("%s %d, %d", mnames[month], dayOfYear, year)
    elseif format == "chinese" then
        -- Chinese style: YYYY年MM月DD日
        return string.format("%d年%02d月%02d日", year, month, dayOfYear)
    else
        -- Default to ISO short
        return string.format("%d-%02d-%02d", year, month, dayOfYear)
    end
end

-- Default colors for first 28 bars/periods
local defaultColor = {"#6ca","#ff9","#6cf","#c96","#fcc","#9f9","#96c","#cc6","#ccc","#f66","#6c6","#99f","#c66","#f9c",
					  "#396","#ff3","#06c","#963","#c9c","#9c6","#c63","#c96","#999","#c03","#393","#939","#996","#f69"}
			
-- The default width of annotations (in em)		  
local defaultAW = 8
-- Previous version default width (in em)
local oldDefaultAW = 7

-- Function to turn blank arguments back into nil
-- Parameters:
--    s = a string argument
-- Returns
--    if s is empty, turn back into nil (considered false by Lua)
local function ignoreBlank(s)
	if s == "" then
		return nil
	end
	return s
end

-- Function to suppress incorrect CSS values
-- Parameters:
--    val = dimensional value 
--    unit = unit of value
--    nonneg = [bool] value needs to be non-negative
--    formatstr = optional format string
-- Returns:
--    correct string for html, or nil if val is negative
local function checkDim(val, unit, nonneg, formatstr)
	if not val then
		return nil
	end
	val = tonumber(val)
	if not val or (nonneg and val < 0) then
		return nil
	end
	if formatstr then
		return mw.ustring.format(formatstr,val)..unit
	end
	return val..unit
end

-- function to scan argument list for pattern
-- Parameters:
--   args = an argument dict that will be scanned for one or more patterns
--   patterns = a list of Lua string patters to scan for
--   other = a list of other argument specification lists
--      each element o corresponds to a new argument to produce in the results
--         o[1] = key in new argument list
--         o[2] = prefix of old argument
--         o[3] = suffix of old argument
-- Returns:
--   new argument list that matches patterns specified, with new key names
--
-- This function makes the Lua module scalable, by specifying a list of string patterns that
-- contain relevant arguments for a single graphical element, e.g., "period(%d+)". These
-- patterns should have exactly one capture that returns a number.
--
-- When such a pattern is detected, the number is extracted and then other arguments
-- with the same number is searched for. Thus, if "period57" is detected, other relevant
-- arguments like "period57-text" are searched for and, if non-empty, are copied to the
-- output list with a new argument key. Thus, there is {"text","period","-text"}, and
-- "period(%d+)" detects period57, the code will look for "period57-text" in the input
-- and copy it's value to "text" on the output.
--
-- This function thus pulls all relevant arguments for a single graphical item out, and
-- makes an argument list to call a function to produce a single element (such as a bar or note)
function p._scanArgs(args,patterns,other)
	local result = {}
	for _, p in pairs(patterns) do
		for k, v in pairs(args) do
			local m = tonumber(mw.ustring.match(k,p))
			-- if there is a matching argument, and it's not blank
			-- and we haven't handled that match yet, then find other
			-- arguments and copy them into output arg list. 
			-- We have to handle blank arguments for backward compatibility with the template
			-- we check for an existing output with item m to save time
			if m and v ~= "" and not result[m] then
				local singleResult = {}
				for _, o in ipairs(other) do
					local foundVal = args[(o[2] or "")..m..(o[3] or "")]
					if foundVal then
						singleResult[o[1]] = foundVal
					end
				end
				-- A hack: for any argument number m, there is a magic list of default
				-- colors. We copy that default color for m into the new argument list, in 
				-- case it's useful. After this, m is discarded
				singleResult.defaultColor = defaultColor[m]
				result[m] = singleResult
			end
		end
	end
	-- Squeeze out all skipped values. Thus, continguous argument numbers are not
	-- required: the module can get called with bar3, bar17, bar59 and it will only produce
	-- three bars, in numerical order that they were called (3, 17, 59)
	return compressSparseArray(result)
end

-- Function to compute the numeric step in the timescale
-- Parameters:
--   p1, p2 = lower and upper bounds of timescale
--   dateMode = whether we're in date mode (for appropriate increments)
-- Returns:
--   round step size that produces ~10 steps between p1 and p2
function p._calculateIncrement(p1, p2, dateMode)
    local d = math.abs(p1-p2)
    if d < 1e-10 then
        return 1e-10
    end
    
    -- In date mode, use special increments for smaller time periods
    if dateMode then
        if d <= 0.1 then -- Less than about a month
            return 1/365  -- 1 day increment
        elseif d <= 0.3 then -- About 3-4 months
            return 7/365 -- 1 week increment
        elseif d <= 2 then -- Less than 2 years
            return 1/12  -- 1 month increment
        elseif d <= 10 then -- Less than 10 years
            return 0.25  -- 3 month/quarter increment
        elseif d <= 30 then -- Less than 30 years
            return 1     -- 1 year increment
        end
        -- Otherwise fall through to standard calculation
    end
    
    local logd = math.log10(d)
    local n = math.floor(logd)
    local frac = logd-n
    local prevPower = math.pow(10,n-1)
    if frac < 0.5*math.log10(2) then
        return prevPower
    elseif frac < 0.5 then
        return 2*prevPower
    elseif frac < 0.5*math.log10(50) then
        return 5*prevPower
    else
        return 10*prevPower
    end
end

-- Signed power function for squashing timeline to be more readable
function p._signedPow(x,p)
	if x < 0 then
		return -math.pow(-x,p)
	end
	return math.pow(x,p)
end

-- Function to convert from time to location in HTML
-- Arguments:
--   t = time
--   from = earliest time in timeline
--   to = latest time in timeline
--   height = height of timeline (in some units)
--   scaling = method of scaling ('linear' or 'sqrt' or 'pow')
--   power = power law of scaling (if scaling='pow')
function p._scaleTime(t, from, to, height, scaling, power)
	if scaling == 'pow' then
		from = p._signedPow(from,power)
		to = p._signedPow(to,power)
		t = p._signedPow(t,power)
	end
	return height*(to-t)/(to-from)
end

-- Utility function to create HTML container for entire date timeline
-- Parameters:
--   container = HTML container for title
--   args = arguments passed to main
--      args["instance-id"] = unique string per date timeline per page
--      args.embedded = is timeline embedded in another infobox?
--      args.align = float of timeline (default=right)
--      args.margin = uniform margin around timeline
--      args.bodyclass = CSS class for whole container
--      args.collapsible = make timeline collapsible
--      args.state = set collapse state
-- Returns;
--   html div object that is root of DOM for date timeline
--
--  CSS taken from previous version of [[Template:Grpahical timeline]]
local function createContainer(args)
    args.align = args.align or "right"
    local container = mw.html.create('table')
    container:attr("id","Container"..(args["instance-id"] or ""))
    container:attr("role","presentation")
    container:addClass(args.bodyclass)
    container:addClass("toccolours")
    container:addClass("searchaux")
    if not args.embedded then
        if args.state == "collapsed" then
            args.collapsible = true
            container:addClass("mw-collapsed")
            container:addClass("nomobile")
        elseif args.state == "autocollapse" then
            args.collapsible = true
            container:addClass("autocollapse")
            container:addClass("nomobile")
        end
        if args.collapsible then
            container:addClass("mw-collapsible")
        end
    end
    container:css("text-align","left")
    container:css("padding","0 0.5em")
    container:css("border-style",args.embedded and "none" or "solid")
    if args.embedded then
        container:css("margin","auto")
    else
        -- Handle special alignment cases
        if args.align == "center" then
            container:css("margin","0.3em auto 0.8em auto")
            container:css("float","none")
            container:css("display","table") -- Ensure it can be centered
        elseif args.align == "none" then
            local margins = {}
            margins[1] = args.margin or "0.3em"
            margins[2] = args.margin or "1.4em"
            margins[3] = args.margin or "0.8em"
            margins[4] = args.margin or "1.4em"
            container:css("margin",table.concat(margins," "))
            container:css("float","none")
        else
            -- Original floating behavior for left/right
            container:css("float",args.align)
            if args.align == "right" or args.align == "left" then
                container:css("clear",args.align)
            end
            local margins = {}
            margins[1] = args.margin or "0.3em"
            margins[2] = (args.align == "right" and 0) or args.margin or "1.4em"
            margins[3] = args.margin or "0.8em"
            margins[4] = (args.align == "left" and 0) or args.margin or "1.4em"
            container:css("margin",table.concat(margins," "))
        end
    end
    
    container:css("overflow","hidden")
    return container
end

-- Utility function to create title for date timeline
-- Parameters:
--   args = arguments passed to main
--      args["instance-id"] = unique string per date timeline per page
--      args["title-color"] = background color for title
--      args.title = title of timeline
-- Returns;
--   html div object that is the title
--
--  CSS taken from previous version of [[Template:Grpahical timeline]]
local function createTitle(container,args)
	container:attr("id","Title"..(args["instance-id"] or ""))
	local bottomPadding = args["link-to"] and (not args.embedded) 
	   and (not args.collapsible) and "0" or "1em"
	container:css("padding","1em 1em "..bottomPadding.." 1em")
	local title = container:tag('div')
	title:css("background-color",ignoreBlank(args["title-colour"] or args["title-color"] or "#77bb77"))
	title:css("padding","0 0.2em 0 0.2em")
	title:css("font-weight","bold")
	title:css("text-align","center")
	title:wikitext(args.title)
end

-- Utility function to create optional navbox header for timeline
-- Parameters:
--   container = container for navbox header
--   args = arguments passed to main
--      args.title = title of timeline
--      args["link-to"] = name of parent template (without namespace)
-- Returns;
--   html div object that is the navbox header
--
--  CSS taken from previous version of [[Template:Grpahical timeline]]
local function navboxHeader(container,args)
	local frame = mw.getCurrentFrame()
    container:attr("id","Navbox"..(args["instance-id"] or ""))
    local topMargin = args.title and "0" or "0.2em"
    container:css("padding","0")
	container:css("margin",topMargin.." 1em 0 0")
	container:css("text-align","right")
	container:wikitext(frame:expandTemplate{title="Navbar",args={"Template:"..args["link-to"]}})
end

-- ==================
-- TIME AXIS AND BARS
-- ==================

--Function to create HTML time axis on left side of timeline
--Arguments:
--  container = HTML parent object
--  args = arguments passed to main
--    args.from = beginning (earliest) time of timeline
--    args.to = ending (latest) time of timeline
--    args.height = height of timeline
--    args["height-unit"] = unit of height (default args.unit)
--    args.unit = unit of measurement (default em)
--    args["instance-id"] = unique string per date timeline per page
--    args["scale-increment"] = gap between time ticks (default=automatically computed)
--    args.scaling = method of scaling (linear or sqrt, linear by default)
--    args["label-freq"] = frequency of labels (per major tick)
-- Returns;
--   html div object for the time axis
--
--  CSS taken from previous version of [[Template:Grpahical timeline]]
function p._scalemarkers(container,args)
	local height = tonumber(args.height) or 36
	local unit = args["height-unit"] or args.unit or "em"
	container:attr("id","Scale"..(args["instance-id"] or ""))
	container:css("width","4.2em")
	args.computedWidth = args.computedWidth+4.2
	container:css("position","relative")
	container:css("float","left")
	container:css("font-size","100%")
	container:css("height",checkDim(height,unit,true))
    container:css("border-right","1px solid #242020")
	local incr = args["scale-increment"] or p._calculateIncrement(args.from, args.to, args.dateMode)
	-- step through by half the desired increment, alternating small and large ticks
	-- put labels every args["label-freq"] large ticks
	local labelFreq = args["label-freq"] or 1
    labelFreq = labelFreq*2 -- account for minor ticks
    local halfIncr = incr/2
    local tIndex = math.ceil(args.from/incr)*2 -- always start on a label
    local toIndex = math.floor(args.to/halfIncr)
    local tickCount = 0
    while tIndex <= toIndex do
        local t = tIndex*halfIncr
        local div = container:tag("div")
        div:css("float","right")
        div:css("position","absolute")
        div:css("right","-1px")
        div:css("top",checkDim(p._scaleTime(t,args.from,args.to,height,args.scaling,args.power),
                               unit,nil,"%.2f"))
        div:css("transform","translateY(-50%)")
        local span = div:tag("span")
        span:css("font-size","90%")
        local text = ""
        if tickCount%labelFreq == 0 then
			if args.dateMode then
				-- In date mode, show dates according to scale
				local dateFormat = args.dateFormat or "short"

				-- For larger scales just show year
				if incr >= 1 then
					if t < 0 then
						text = mw.ustring.format("&minus;%d&nbsp;", math.floor(-t))
					else
						text = mw.ustring.format("%d&nbsp;", math.floor(t))
					end
				-- For medium scales (months), show year and month
				elseif incr >= 0.08 then  -- About a month
					local year = math.floor(t)
					local month = math.floor((t - year) * 12) + 1
					local monthNames = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", 
									  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
					text = mw.ustring.format("%s %d&nbsp;", monthNames[month], year)
				-- For detailed scales, show full date
				else
					text = formatDecimalYear(t, "short") .. "&nbsp;"
				end

				-- Add tooltip with full date, respecting dateFormat if "year" or "chinese", else use "full"
				local tooltipFormat = (args.dateFormat == "year" or args.dateFormat == "chinese") and args.dateFormat or "full"
				div:attr("title", formatDecimalYear(t, tooltipFormat))
            else
                -- Standard decimal year display
                if t < 0 then
                    text = mw.ustring.format("&minus;%g&nbsp;",-t)
                else
                    text = mw.ustring.format("%g&nbsp;",t)
                end
            end
        end
		if tickCount%2 == 0 then
			text = text.."&mdash;"
		else
			text = text.."&ndash;"
		end
		span:wikitext(text)
		tIndex = tIndex + 1
		tickCount = tickCount + 1
	end
end

-- Function to create timeline container div
-- Arguments:
--   container = HTML parent object
--   args = arguments passed to main
--     args["plot-colour"] = background color for timeline
--     args["instance-id"] = unique string per date timeline per page
--     args.height = height of timeline (36 by default)
--     args.width = width of timeline (10 by default)
--     args["height-unit"] = unit of height measurement (args.unit by default)
--     args["width-unit"] = unit of width measurement (args.unit by default)
--     args.unit = unit of measurement (em by default)
--     args["hide-timeline"] = if "yes", hides the timeline block but keeps axis and annotations
-- Returns:
--   timeline HTML object created
local function createTimeline(container,args)
    local color = ignoreBlank(args["plot-colour"] or args["plot-color"])
    container:attr("id","Timeline"..(args["instance-id"] or ""))
    container:addClass("toccolours")
    container:css("position","relative")
    container:css("font-size","100%")
    container:css("height",checkDim(args.height or 36,args["height-unit"] or args.unit or "em",true))
    container:css("padding","0px")
    container:css("float","left")
    
    -- Check if timeline should be hidden
    local hideTimeline = args["hide-timeline"] == "yes" or args["hide-timeline"] == "true" or args["hide-timeline"] == "1"
    
    -- Set width based on hide-timeline parameter
    local width = args.width or 10
    if hideTimeline then
        width = 0.1 -- Use minimal width to keep structure but hide visually
    end
    
    local widthUnit = args["width-unit"] or args.unit or "em"
    container:css("width",checkDim(width,widthUnit,true))
    
    if widthUnit == "em" then
        args.timelineWidth = width
    elseif widthUnit == "px" then
        args.timelineWidth = width/13.3
    else
        args.timelineWidth = hideTimeline and 0.1 or 10
    end
    
    args.computedWidth = args.computedWidth+(hideTimeline and 0.1 or args.timelineWidth)
    container:css("border","none")
    
    -- Only set background color if not hidden
    if not hideTimeline then
        container:css("background-color",color)
    else
        container:css("background-color","transparent")
        container:css("opacity", "0")
    end
    
    container:addClass("notheme")
    
    return container
end

-- Function to draw single bar (or box)
-- Arguments:
--   container = parent HTML object for bar
--   args = arguments for this box
--     args.text = text to display
--     args.nudgedown = distance to nudge text down (in em)
--     args.nudgeup = distance to nudge text up (in em)
--     args.nudgeright = distance to nudge text right (in em)
--     args.nudgeleft = distance to nudge text left (in em)
--     args.colour = color of bar (default to color assigned to bar number)
--     args.left = fraction of timeline width for left edge of bar (default 0)
--     args.right = fraction of timeline width for right edge of bar (default 1)
--     args.to = beginning (bottom) of bar, in time units (default timeline begin)
--     args.from = end (top) of bar, in time units (default timeline end)
--     args.height = timeline height
--     args.width = timeline width
--     args["height-unit"] = units of timeline height (default args.unit)
--     args["width-unit"] = units of timeline width (default args.unit)
--     args.unit = units for timeline dimensions (default em)
--     args.border-style = CSS style for top/bottom of border (default "solid" if args.border)
function p._singleBar(container,args)
    args.text = args.text or "&nbsp;"
    args.nudgedown = (tonumber(args.nudgedown) or 0) - (tonumber(args.nudgeup) or 0)
    args.nudgeright = (tonumber(args.nudgeright) or 0) - (tonumber(args.nudgeleft) or 0)
    args.colour = args.colour or args.defaultColor
    args.left = tonumber(args.left) or 0
    args.right = tonumber(args.right) or 1
    
    -- Handle date strings for 'to' parameter
    local barTo = tonumber(args.to)
    local originalToDate = args.to
    if not barTo then
        barTo = dateToDecimalYear(args.to)
        if not barTo then
            barTo = args["tl-to"]
        end
    end
    args.to = barTo
    
    -- Handle date strings for 'from' parameter
    local barFrom = tonumber(args.from)
    local originalFromDate = args.from
    if not barFrom then
        barFrom = dateToDecimalYear(args.from)
        if not barFrom then
            barFrom = args["tl-from"]
        end
    end
    args.from = barFrom
    
    args.height = tonumber(args.height) or 36
	args.width = tonumber(args.width) or 10
	args["height-unit"] = args["height-unit"] or args.unit or "em"
	args["width-unit"] = args["width-unit"] or args.unit or "em"
	args.border = tonumber(args.border)
	args["border-style"] = args["border-style"] or ((args.border or args["border-colour"]) and "solid") or "none"
	-- the HTML element for the box/bar itself
	local bar = container:tag('div')
	bar:css("font-size","100%")
	bar:css("background-color",ignoreBlank(args.colour or "#aaccff"))
	bar:css("border-width",checkDim(args.border,args["height-unit"],true))
	bar:css("border-color",ignoreBlank(args["border-colour"]))
	bar:css("border-style",args["border-style"].." none")
	bar:css("position","absolute")
	bar:css("text-align","center")
	bar:css("margin","0")
	bar:css("padding","0")
	bar:css("pointer-events", "none")
	bar:addClass("notheme")
	local bar_top = p._scaleTime(args.to,args["tl-from"],args["tl-to"],args.height,args.scaling,args.power)
    local bar_bottom = p._scaleTime(args.from,args["tl-from"],args["tl-to"],args.height,args.scaling,args.power)
    local bar_height = bar_bottom-bar_top
    bar:css("position", "absolute") -- Ensure absolute positioning
    bar:css("top", checkDim(bar_top, args["height-unit"], nil, "%.3f"))
	if args["border-style"] ~= "none" and args.border then
		bar_height = bar_height-2*args.border
	end
	bar:css("height",checkDim(bar_height,args["height-unit"],true,"%.3f"))
	bar:css("left",checkDim(args.left*args.width,args["width-unit"],nil,"%.3f"))
	bar:css("width",checkDim((args.right-args.left)*args.width,args["width-unit"],true,"%.3f"))
	
    -- Add tooltip with date information if in date mode
    if args.dateMode then
        local tooltipText
        if type(originalFromDate) == "string" and type(originalToDate) == "string" then
            tooltipText = string.format("From: %s\nTo: %s", originalFromDate, originalToDate)
        else
            tooltipText = string.format("From: %s\nTo: %s", 
                formatDecimalYear(args.from, args.dateFormat or "short"),
                formatDecimalYear(args.to, args.dateFormat or "short"))
        end
        bar:attr("title", tooltipText)
    end
    
    -- within the bar, use a div to nudge text away from center
	local textParent = bar
	if not args.alignBoxText then
	    local nudge = bar:tag('div')
	    nudge:css("font-size","100%")
	    nudge:css("position","relative")
	    nudge:css("top",checkDim(args.nudgedown,"em",nil))
	    nudge:css("left",checkDim(args.nudgeright,"em",nil))
	    nudge:css("pointer-events", "none")
	    textParent = nudge
	end
	-- put text div as child of nudge div (if exists)
	local text = textParent:tag('div')
	text:css("position","relative")
	text:css("text-align","center")
	text:css("font-size",ignoreBlank(args.textsize))
	text:css("vertical-align","middle")
	text:addClass("notheme")
	local text_bottom = -0.5*bar_height
	text:css("display","block")
	text:css("bottom",checkDim(text_bottom,args["height-unit"],nil,"%.3f"))
	text:css("transform","translateY(-50%)")
	text:css("z-index","5")
    text:css("pointer-events", "initial")
	text:wikitext(ignoreBlank(args.text))
end

-- Function to render all bars/boxes in timeline
-- Arguments:
--   container = parent HTML object
--   args = arguments to main function
--
--  Global (main) arguments are parsed, individual box arguments are picked out
--  and passed to p._singleBar() above
--
--  The function looks for bar*-left, bar*-right, bar*-from, or bar*-to,
--     where * is a string of digits. That string of digits is then used to
--     find corresponding parameters of the individual bar.
--  For example, if bar23-left is found, then bar23-colour turns into local colour,
--     bar23-left turns into local left, bar23-from turns into local from, etc.
function p._bars(container,args)
	local barArgs = p._scanArgs(args,{"^bar(%d+)-left$","^bar(%d+)-right$","^bar(%d+)-from","^bar(%d+)-to"},
		{{"text","bar","-text"},
	     {"textsize","bar","-font-size"},
		 {"nudgedown","bar","-nudge-down"},
		 {"nudgeup","bar","-nudge-up"},
		 {"nudgeright","bar","-nudge-right"},
		 {"nudgeleft","bar","-nudge-left"},
		 {"colour","bar","-colour"},
		 {"colour","bar","-color"},
		 {"border","bar","-border-width"},
		 {"border-colour","bar","-border-colour"},
		 {"border-colour","bar","-border-color"},
		 {"border-style","bar","-border-style"},
		 {"left","bar","-left"},
		 {"right","bar","-right"},
		 {"from","bar","-from"},
		 {"to","bar","-to"}})
    -- The individual bar arguments are placed into the barArgs table
    -- Iterating through barArgs picks out the 
	for _, barg in ipairs(barArgs) do
		-- barg is a table with the local arguments for one bar.
		-- barg needs to have some global arguments copied into it:
		barg["tl-from"] = args.from
		barg["tl-to"] = args.to
		barg.height = args.height
		barg.width = args.width
		barg["height-unit"] = args["height-unit"]
		barg["width-unit"] = args["width-unit"]
		barg.unit = args.unit
		barg.scaling = args.scaling
		barg.power = args.power
		barg.alignBoxText = not args["disable-box-align"]
		-- call _singleBar with the local arguments for one bar
		p._singleBar(container,barg)
	end
end

-- Function to draw a bar corresponding to a geological period
-- Arguments:
--   container = parent HTML object
--   args = global arguments passed to main
--
-- This function is just like _bars(), above, except with defaults for periods:
--    a period bar is triggered by period* (* = string of digits)
--    all other parameters start with "period", not "bar"
--    colour, from, and to parameters default to data from named period
--    text is a wikilink to period article
function p._periods(container,args)
    local frame = mw.getCurrentFrame()
    local periodArgs = p._scanArgs(args,{"^period(%d+)$"},
        {{"text","period","-text"},
         {"textsize","period","-font-size"},
         {"period","period"},
         {"nudgedown","period","-nudge-down"},
         {"nudgeup","period","-nudge-up"},
         {"nudgeright","period","-nudge-right"},
         {"nudgeleft","period","-nudge-left"},
         {"colour","period","-colour"},
         {"colour","period","-color"},
         {"border-width","period","-border-width"},
         {"border-colour","period","-border-colour"},
         {"border-colour","period","-border-color"},
         {"border-style","period","-border-style"},
         {"left","period","-left"},
         {"right","period","-right"},
         {"from","period","-from"},
         {"to","period","-to"}})
    -- Iterate through period* arguments, translating much like bar* arguments
    -- Supply period defaults to local arguments, also
    for _, parg in ipairs(periodArgs) do
        parg.text = parg.text or ("[["..parg.period.."]]")
        parg.textsize = "90%"
        parg.colour = parg.colour or frame:expandTemplate{title=periodColor,args={parg.period}}
        
        -- Handle date strings for period from/to
        if not parg.from then
            local periodStart = frame:expandTemplate{title=periodStart,args={parg.period}}
            -- Try to parse as date first
            parg.from = dateToDecimalYear(periodStart)
            -- If not a date, use old method
            if not parg.from then
                parg.from = tonumber("-"..periodStart)
            end
        end
        
        if not parg.to then
            local periodEnd = frame:expandTemplate{title=periodEnd,args={parg.period}}
            -- Try to parse as date first
            parg.to = dateToDecimalYear(periodEnd)
            -- If not a date, use old method
            if not parg.to then
                parg.to = tonumber("-"..periodEnd)
            end
        end
        
        -- Fix: Only compare when both values are numbers
        local fromNum = tonumber(parg.from)
        local toNum = tonumber(parg.to)
        local argsFromNum = tonumber(args.from)
        local argsToNum = tonumber(args.to)
        
        if fromNum and argsFromNum and fromNum < argsFromNum then
            parg.from = args.from
        end
        if toNum and argsToNum and toNum > argsToNum then
            parg.to = args.to
        end
        
        -- Copy date mode parameters
        parg.dateMode = args.dateMode
        parg.dateFormat = args.dateFormat
        
        parg["tl-from"] = args.from
        parg["tl-to"] = args.to
        parg.height = args.height
        parg.width = args.width
        parg["height-unit"] = args["height-unit"]
        parg["width-unit"] = args["width-unit"]
        parg.unit = args.unit
        parg.scaling = args.scaling
        parg.power = args.power
        parg.alignBoxText = not args["disable-box-align"]
        p._singleBar(container,parg)
    end
end

-- ===========
-- ANNOTATIONS
-- ===========

-- Function to render a single note (annotation)
-- Arguments:
--    container = parent HTML object
--    args = arguments for this single note
--       args.text = text to display in note
--       args.noarr = bool, true if no arrow should be used
--       args.height = height of timeline
--       args.unit = height units
--       args.at = position of annotation (in time units)
--       args.colour = color of text in note
--       args.textsize = size of text (default 90%)
--       args.nudgeright = nudge text (and arrow) to right (in em)
--       args.nudgeleft = nudge text (and arrow) to left (in em)
--       Following parameters are only applicable to "no arrow" case or when
--       args.alignArrow is false:
--         args.nudgedown = nudge text down (in em)
--         args.nudgeup = nudge text up (in em)
--         args.aw = annotation width (in em)

function p._singleNote(container,args)
    -- Ensure some parameters default to sensible values
    args.height = tonumber(args.height) or 36
    
    -- Handle date string for 'at' parameter
    local noteAt = tonumber(args.at)
    local originalAtDate = args.at
    if not noteAt then
        noteAt = dateToDecimalYear(args.at)
        if not noteAt then
            noteAt = 0.5*(args.to+args.from)
        end
    end
    args.at = noteAt
    
    args.colour = args.colour or "var( --color-base, #000)"
    args.aw = tonumber(args.aw)
              -- if string is centering, use old width to not break it
              or mw.ustring.find(args.text,"center",1,true) and oldDefaultAW
              or defaultAW
    args.textsize = args.textsize or "90%"
    
    -- IMPORTANT CHANGE: Calculate nudges first and make sure they're numeric
    local nudgedown = tonumber(args.nudgedown) or 0
    local nudgeup = tonumber(args.nudgeup) or 0
    local nudgeright = tonumber(args.nudgeright) or 0
    local nudgeleft = tonumber(args.nudgeleft) or 0
    
    -- Convert 4 nudge arguments to 2 numeric signed nudge dimensions (right, down)
    args.nudgeright = nudgeright - nudgeleft
    args.nudgedown = nudgedown - nudgeup
    
    -- Container should have no pointer events, only the text should.
    -- This prevents issues with containers overlapping and blocking pointer events on links.
    container:css("pointer-events", "none")
    
    -- Two cases: no arrow, and arrow
    if args.noarr then
        -- First, place a bar that pushes annotation down to right spot
        local bar = container:tag('div')
        bar:addClass("annot-bar")
        bar:css("width","auto")
        bar:css("font-size","100%")
        bar:css("position","absolute")
        bar:css("text-align","center")
        bar:css("pointer-events", "none")
        bar:css("margin-top",checkDim(p._scaleTime(args.at,args.from,args.to,args.height,args.scaling,args.power),
                                      args.unit,nil,"%.3f"))
        -- Now, nudge the text per nudge dimensions
        local nudge = bar:tag('div')
        nudge:addClass("annot-nudge")
        nudge:css("font-size","100%")
        nudge:css("float","left")
        nudge:css("position","relative")
        nudge:css("text-align","left")
        nudge:css("pointer-events", "none")
        nudge:css("top",checkDim(args.nudgedown-0.75,"em",nil))
        nudge:css("left",checkDim(args.nudgeright,"em",nil))
        nudge:css("width",checkDim(args.aw,"em",true))
        -- Finally, place a dev for the text
        local text = nudge:tag('div')
        text:css("position","relative")
        text:css("width","auto")
        text:css("z-index","10")
        text:css("font-size",ignoreBlank(args.textsize))
        text:css("color",ignoreBlank(args.colour))
        text:css("vertical-align","middle")
        text:css("line-height","105%")
        text:css("bottom","0")
        -- Ensure that the text can be interacted with:
        text:css("pointer-events","initial")
        -- Add tooltip with date information if in date mode
        if args.dateMode then
            local tooltipText
            tooltipText = "Date: " .. formatDecimalYear(args.at, args.dateFormat or "short")
            text:attr("title", tooltipText)
        end
        text:wikitext(ignoreBlank(args.text))
    else
        -- Arrow case
        local tbl = container:tag('table')
        tbl:attr("role","presentation")
        tbl:css("position","absolute")
        tbl:css("z-index","15")
        local at_location = p._scaleTime(args.at,args.from,args.to,args.height,args.scaling,args.power)
        
        -- IMPORTANT FIX: Directly incorporate vertical nudge into the top position
        -- Calculate nudged position by adding the vertical nudge directly
        local verticalPosition = at_location
        if nudgedown > 0 then
            verticalPosition = verticalPosition + nudgedown
        elseif nudgeup > 0 then
            verticalPosition = verticalPosition - nudgeup
        end
        
        -- Apply the calculated position
        tbl:css("top", checkDim(at_location, args.unit, nil, "%.3f"))
        tbl:css("left", checkDim(args.nudgeright, "em", nil))
        tbl:css("transform", "translateY(-50%)")
		tbl:css("overflow", "visible")
        
        local row = tbl:tag('tr')
        local arrowCell = row:tag('td')
        arrowCell:css("padding","0")
        arrowCell:css("text-align","left")
        arrowCell:css("vertical-align","middle")
        local arrowSpan = arrowCell:tag('span')
        arrowSpan:css("color",args.colour)
        arrowSpan:wikitext("&#8592;") --- HTML for left-pointing arrow
        local textCell = row:tag('td')
        textCell:css("padding","0")
        textCell:css("text-align","left")
        textCell:css("vertical-align","middle")
        -- Wrap only the text so that arrow stays anchored
        local textParent = textCell:tag('div')
        textParent:addClass("annot-nudge")
        textParent:css("font-size","100%")
        textParent:css("position","relative")
        -- apply vertical and horizontal nudge to text only
        textParent:css("top",  checkDim(args.nudgedown,  "em", nil))
        textParent:css("left", checkDim(args.nudgeright, "em", nil))
        
        local text = textParent:tag('div')
        text:css("z-index","10")
        text:css("font-size",ignoreBlank(args.textsize))
        text:css("color",ignoreBlank(args.colour))
        text:css("display","block")
        text:css("line-height","105%") --- don't crunch multiple lines of text
        text:css("bottom","0")
        -- Ensure that the text can be interacted with:
        text:css("pointer-events","initial")
        -- Add tooltip with date information if in date mode
        if args.dateMode then
            local tooltipText
            if type(originalAtDate) == "string" then
                tooltipText = "Date: " .. originalAtDate
            else
                tooltipText = "Date: " .. formatDecimalYear(args.at, args.dateFormat or "short")
            end
            text:attr("title", tooltipText)
        end
        text:wikitext(ignoreBlank(args.text))
    end
end

-- Function to render all annotations in timeline
-- Arguments:
--   container = parent HTML object
--   args = arguments to main function
--
--  Global (main) arguments are parsed, individual box arguments are picked out
--  and passed to p._singleNote() above
--
--  The function looks for note*, where * is a string of digits
--     That string of digits is then used to find corresponding parameters of the individual note.
--  For example, if note23 is found, then note23-colour turns into local colour,
--     note-at turns into local at, note-texdt turns into local text, etc.
--
--  args["annotation-width"] overrides automatically determined width of annotation div
function p._annotations(container,args)
	local noteArgs = p._scanArgs(args, {"^note(%d+)$"},
		{
		{"text",       "note"},
		{"noarr",      "note", "-remove-arrow"},
		{"noarr",      "note", "-no-arrow"},
		{"textsize",   "note", "-size"},
		{"textsize",   "note", "-font-size"},
		{"nudgedown",  "note", "-nudge-down"},
		{"nudgedown",  "note", "-nudgedown"},
		{"nudgeup",    "note", "-nudge-up"},
		{"nudgeup",    "note", "-nudgeup"},
		{"nudgeright", "note", "-nudge-right"},
		{"nudgeleft",  "note", "-nudge-left"},
		{"colour",     "note", "-colour"},
		{"colour",     "note", "-color"},
		{"at",         "note", "-at"},
		}
	)
	if #noteArgs == 0 then
		return
	end
	-- a div to hold all of the notes
	local notes= container:tag('td')
	notes:attr("id","Annotations"..(args["instance-id"] or ""))
	notes:css("padding","0")
	notes:css("margin","0.7em 0 0.7em 0")
	notes:css("float","left")
	notes:css("position","relative")
	-- Is there a "real" note? If so, leave room for it
	-- real is: is non-empty and (has arrow or isn't nudged left)
	local realNote = false
	for _, narg in ipairs(noteArgs) do
		local left = (tonumber(narg.nudgeleft) or 0)-(tonumber(narg.nudgeright) or 0)
		if narg.text ~= "" and (not narg.noarr or left <= 0) then
			realNote = true
			args.hasRealNote = true -- record realNote boolean in args for further use
			break
		end
	end
	-- width of notes holder depends on whethere there are any "real" notes
	-- width can be overriden
	local aw = tonumber(args["annotations-width"]) or (realNote and defaultAW) or 0
	aw = aw+0.22*args.timelineWidth
	notes:css("width",checkDim(aw,"em",true,"%.3f"))
	args.computedWidth = args.computedWidth+aw
	local height = tonumber(args.height) or 36
	local unit = args["height-unit"] or args.unit or "em"
	notes:css("height",checkDim(height,unit,true))
	for _, narg in ipairs(noteArgs) do
		--- copy required global parameters to local note args
		narg.from = args.from
		narg.to = args.to
		narg.height = args.height
		narg.unit = args["height-unit"] or args["width-unit"] or "em"
		narg.aw = args["annotations-width"]
		narg.alignArrow = not args["disable-arrow-align"]
		narg.scaling = args.scaling
		narg.power = args.power
		p._singleNote(notes,narg)
	end
end

--  ====================
--  LEGENDS AND CAPTIONS
--  ====================

-- Function to render a single legend (below the timeline)
-- Arguments:
--   container = parent HTML object
--   args = argument table for this legend
--     args.colour = color to show in square
--     args.text = text that describes color
function p._singleLegend(container,args)
	if not args.text then  -- if no text, not a sensible legend
		return
	end
	args.colour = args.colour or args.defaultColor or "transparent"
	local row = container:tag('tr')
	local squareCell = row:tag('td')
	squareCell:css("padding",0)
	local square = squareCell:tag('span')
	square:css("background",ignoreBlank(args.colour))
	square:css("padding","0em .1em")
	square:css("border","solid 1px #242020")
	square:css("height","1.5em")
	square:css("width","1.5em")
	square:css("margin",".25em .9em .25em .25em")
	square:wikitext("&emsp;")
	local textCell = row:tag('td')
	textCell:css("padding",0)
	local text = textCell:tag('div')
	text:wikitext(args.text)
end

function p._legends(container,args)
	local legendArgs = p._scanArgs(args,{"^legend(%d+)$"},
		{{"text","legend"},
		 {"colour","bar","-colour"},
		 {"colour","bar","-color"},
		 {"colour","legend","-colour"},
		 {"colour","legend","-color"}
		 })
	if #legendArgs == 0 then
		return
	end
	local legendRow = container:tag('tr')
	local legendCell = container:tag('td')
    legendCell:attr("id","Legend"..(args["instance-id"] or ""))
	legendCell:attr("colspan",3)
	legendCell:css("padding","0 0.2em 0.7em 1em")
	local legend = legendCell:tag('table')
	legend:attr("id","Legend"..(args["instance-id"] or ""))
    legend:attr("role","presentation")
    legend:addClass("toccolours")
	legend:css("margin-left","3.1em")
	legend:css("border-style","none")
	legend:css("float","left")
	legend:css("clear","both")
	legend:css("overflow","visible")
	for _,larg in ipairs(legendArgs) do
		p._singleLegend(legend,larg)
	end
end

local helpString = [=[

----

'''Usage instructions'''

----

Copy the text below, adding multiple bars, legends and notes as required.
<br>Comments, enclosed in <code><!-</code><code>- -</code><code>-></code>, should be removed.

Remember:
* You must use <code>{</code><code>{!}</code><code>}</code> wherever you want a {{!}} to be
: rendered in the timeline
* Large borders will displace bars in many browsers
* Text should not be wider than its containing bar,
: as this may cause compatibility issues
* Units default to [[em (typography){{!}}em]], the height and width of an 'M'.

See {{tl|Date timeline}} for full documentation.

{{Date timeline/blank}}}}]=]

local function createCaption(container,args)
	local captionRow = container:tag("tr")
	local captionCell = captionRow:tag("td")
    captionCell:attr("id","Caption"..(args["instance-id"] or ""))
	captionCell:attr("colspan",3)
	captionCell:css("padding","0")
	captionCell:css("margin","0 0.2em 0.7em 0.2em")
	local caption = captionCell:tag("div")
	caption:attr("id","Caption"..(args["instance-id"] or ""))
	caption:addClass("toccolours")
	if args.embedded then
		caption:css("margin","0 auto")
		caption:css("float","left")
	else
		caption:css("margin","0 0.5em")
	end
	caption:css("border-style","none")
	caption:css("clear","both")
	caption:css("text-align","center")
	local widthUnit = args["width-unit"] or args.unit or "em"
	local aw = tonumber(args["annotations-width"]) or (args.hasRealNote and defaultAW) or -0.25
	aw = aw+5+args.timelineWidth
	if aw > args.computedWidth then
		args.computedWidth = aw
	end
	caption:css("width",checkDim(aw,"em",true,"%.3f"))
	caption:wikitext((args.caption or "")..((args.help and args.help ~= "off" and helpString) or ""))
end

function p._main(args)
    -- For backward compatibility with template, all empty arguments are accepted.
    -- But, for some parameters, empty will cause a Lua error, so for those, we convert
    -- empty to nil.
    for _, attr in pairs({"title","link-to","embedded","align","margin",
        "height","width","unit","height-unit","width-unit","scale-increment",
        "annotations-width","disable-arrow-align","disable-box-align","from","to",
        "hide-timeline"}) do
        args[attr] = ignoreBlank(args[attr])
    end
    
    -- Process hide-timeline parameter
    args.hideTimeline = args["hide-timeline"] == "yes" or args["hide-timeline"] == "true" 
                        or args["hide-timeline"] == "1"
    
    -- Check that to > from, and that they're both defined
    -- First try to parse as numbers, then as dates if that fails
    local from = tonumber(args.from)
    if not from then
        from = dateToDecimalYear(args.from)
        if from then
            args.dateMode = true -- Flag that we're using date mode
        else
            from = 0
        end
    end
    
    local to = tonumber(args.to)
    if not to then
        to = dateToDecimalYear(args.to)
        if not to then
            to = 0
        end
    end
    
    if from > to then
        args.from = to
        args.to = from
    else
        args.from = from
        args.to = to
    end
    
    -- Allow explicitly setting date mode
    if args.dateMode == "yes" or args.dateMode == "true" or args.dateMode == "1" then
        args.dateMode = true
    end
    
    -- Add debug mode for troubleshooting date positioning
    if args.debug == "yes" or args.debug == "true" then
        args.debug = true
    end
    
    -- Date mode settings
    if args.dateMode then
        -- Set date format option
        args.dateFormat = args.dateFormat or "short"
        
        -- Date tooltip options
        args.showDateTooltips = args.showDateTooltips ~= "no" and args.showDateTooltips ~= "false"
    end
    
    if args.scaling == 'sqrt' then
        args.scaling = 'pow'
        args.power = 0.5
    end
    if args.scaling == 'pow' then
        args.power = args.power or 0.5
    end
    args.computedWidth = 1.7
    -- Create container table
    local container = createContainer(args)
    -- TITLE
    if args.title and not args.embedded then
        local titleRow = container:tag('tr')
        local titleCell = titleRow:tag('td')
        titleCell:attr("colspan",3)
        createTitle(titleCell,args)
    end
    -- NAVBOX HEADER
    if args["link-to"] and not args.embedded then
        local navboxRow = container:tag('tr')
        local navboxCell = navboxRow:tag('td')
        navboxCell:attr("colspan",3)
        navboxHeader(navboxCell,args)
    end
    local centralRow = container:tag('tr')
    centralRow:css("vertical-align","top")
    -- SCALEBAR
    local scaleCell = centralRow:tag('td')
    scaleCell:css("padding","0")
    scaleCell:css("margin","0.7em 0 0.7em 0")
    p._scalemarkers(scaleCell,args)
    -- TIMELINE
    local timelineCell = centralRow:tag('td')
    timelineCell:css("padding","0")
    timelineCell:css("margin","0.7em 0 0.7em 0")
    local timeline = createTimeline(timelineCell,args)
    -- PERIODS
    p._periods(timeline,args)
    -- BARS
    p._bars(timeline,args)
    -- ANNOTATIONS
    p._annotations(centralRow,args)
    -- LEGEND
    p._legends(container,args)
    -- CAPTION
    createCaption(container,args)
    container:css("min-width",checkDim(args.computedWidth,"em",true,"%.3f"))

    -- Only use an outer div for horizontal scrolling if align=center or align=none
    if args.align == "center" or args.align == "none" then
        local outerDiv = mw.html.create("div")
        outerDiv:css("overflow-x", "auto")
        outerDiv:css("width", "100%")
        outerDiv:node(container)
        return outerDiv
    else
        -- For left/right float, just return the table container
        return container
    end
end

function p.main(frame)
	local args = getArgs(frame,{frameOnly=false,parentOnly=false,parentFirst=true,removeBlanks=false})
	return tostring(p._main(args):allDone())
end

return p