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.

This is a documentation subpage for Climate year. It contains usage information, categories, and other content that is not part of the original template.

This module draws the year-at-a-glance strip of {{Year at a glance}}: twelve columns, one per month, with rows for day and night, temperature, water and light, and the field cycle. Each value cell is coloured on one scale shared by every page, so two places can be compared by eye. It shows native values only. The method and datasets are explained at Help:Climate matches.

Rows

Group Row What it is
Day and night First light, Last light Civil twilight (the sun 6° below the horizon) on the 15th of the month, local clock time
Sunrise, Sunset The sun's upper edge on the horizon, with refraction, on the 15th, local clock time
Daylight Hours and minutes from sunrise to sunset on the 15th
Change Minutes of daylight gained (+) or lost (−) per day around the 15th
Computed from the latitude and longitude with the NOAA solar-position formulas and a flat horizon, so in a deep valley the sun clears the ridges later than shown.
Temperature Avg daily high, Avg daily low The 1981–2010 mean of each day's maximum and minimum air temperature, °C. Single days go well past them.
Swing High minus low, °C
Water and light Rain Mean precipitation for the month, mm
VPD Mean vapour-pressure deficit, kPa
Sunlight Shortwave radiation converted to an approximate daily light integral (× 2.04), mol/m²/day
Field cycle Cycle Planting, growing and harvest, each month drawn as three thirds (early, mid, late)

Where the values come from

  • Accession: its own point, read from Module:Climate profile/accessions (the point sampled, the computed Köppen class and elevation, and the normals of its climate cell). The field cycle comes from the eight cycle fields of {{Accession}}.
  • Appellation: the climate cells holding the accessions linked to it (Has appellation), each cell counted once however many accessions share it, averaged; when none of its accessions has coordinates, its whole area from Module:Climate profile/normals. The field cycle combines the cycles of its linked accessions; when none records one, those of the accessions in its growing area (Has growing area). The note under the strip says which was used.
  • Growing area: its whole area. The field cycle combines the cycles of the accessions in it.
  • Day length is computed at the accession's point, or at the place's centre.

When several accessions are combined, each third of a month shows the furthest stage any of them records there: harvest before planting, planting before growing. Growing fills the time from the end of planting to the start of harvest of one cycle. A cycle with a harvest but no planting shows the harvest alone.

Clock time

Times are local clock time for the country in the page's country field: its standard offset from UTC, plus summer time in the months whose 15th falls inside it (the European rule, the United States rule, the Australian, Chilean and Egyptian rules). A country spanning several zones (Russia, Mongolia, the Democratic Republic of the Congo, Indonesia, Papua New Guinea, Brazil, Australia, the United States, Canada, Mexico) is resolved from the point's longitude and latitude; near a zone border away from the wiki's current points that is approximate. Morocco shows UTC+1; the change to UTC+0 during Ramadan is not shown. A country missing from the table falls back to the nominal offset of the longitude, and the strip says so.

Functions

  • {{#invoke:Climate year|strip}}: the strip, from the arguments of the calling template ({{Year at a glance}}). Empty for a page with no computed climate.
  • {{#invoke:Climate year|has}}: yes when the page would show a strip.

From Lua: _sample(args, frame) resolves everything a strip needs, _render(sample) draws it, _sunEvent and _dayLength compute the sun, _clock(countries, lat, lon) gives the clock, _parseMonth, _band and _cycleText read the field cycle.

Markup

Each value cell carries data-v (the unrounded value) and three style properties, --lw-yr-t, --lw-yr-c and --lw-yr-h, from which Template:Year at a glance/styles.css builds the colour for the light and the dark theme. Each row carries data-row and, where it has one, data-unit; each month heading data-month; the strip data-place.


-- Module:Climate year
-- The year-at-a-glance strip on accession, appellation and growing-area pages: twelve months of day length
-- (computed from the sun's position), monthly climate normals (Module:Climate profile) and the recorded field cycle.
-- Called through {{Year at a glance}}. Native values only. Functions, rules and tests: Module:Climate year/doc.

local p = {}
local CP = require('Module:Climate profile')

local HELP = 'Help:Climate matches'
local STYLES = 'Template:Year at a glance/styles.css'
local MONTHS = { 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' }
local MONTH_FULL = { 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September',
	'October', 'November', 'December' }
-- Day of the year of the 1st of each month in a common (non-leap) year; the strip uses the 15th.
local DOY1 = { 1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 }
local MINUS = '−'

local function trim(s)
	if s == nil then return nil end
	s = mw.text.trim(tostring(s))
	return s ~= '' and s or nil
end

-- A page name as a template argument may carry link brackets or a pipe: [[Ketama|the Ketama area]] → Ketama.
local function bare(s)
	s = trim(s)
	if not s then return nil end
	s = s:gsub('^%[%[', ''):gsub('%]%]$', '')
	s = s:gsub('|.*$', '')
	return trim(s)
end

local function round(x, d)
	local m = 10 ^ (d or 0)
	return math.floor(x * m + 0.5) / m
end

-- string.format with a true minus sign.
local function fnum(fmt, x)
	local s = string.format(fmt, x)
	if s:sub(1, 1) == '-' then
		s = s:sub(2)
		if s:match('^[0.]+$') then return s end -- "-0" reads as 0
		return MINUS .. s
	end
	return s
end

---------------------------------------------------------------------------------------------------------------
-- The sun. NOAA solar-position formulas (Spencer's series for the declination and the equation of time),
-- flat horizon. Sunrise and sunset at a zenith of 90.833° (refraction and the sun's disc), first and last light
-- at civil twilight (96°).

local SUNRISE, CIVIL = 90.833, 96

-- Minutes after local midnight of a sun event on day `doy`, or nil plus 'always' (the sun never goes that far
-- below the horizon that day) or 'never' (it never gets that high).
function p._sunEvent(lat, lon, doy, tzmin, zenith, rising)
	local g = 2 * math.pi / 365 * (doy - 1 + 0.5)
	local eqt = 229.18 * (0.000075 + 0.001868 * math.cos(g) - 0.032077 * math.sin(g)
		- 0.014615 * math.cos(2 * g) - 0.040849 * math.sin(2 * g))
	local decl = 0.006918 - 0.399912 * math.cos(g) + 0.070257 * math.sin(g) - 0.006758 * math.cos(2 * g)
		+ 0.000907 * math.sin(2 * g) - 0.002697 * math.cos(3 * g) + 0.00148 * math.sin(3 * g)
	local la = math.rad(lat)
	local cosha = math.cos(math.rad(zenith)) / (math.cos(la) * math.cos(decl)) - math.tan(la) * math.tan(decl)
	if cosha < -1 then return nil, 'always' end
	if cosha > 1 then return nil, 'never' end
	local ha = math.deg(math.acos(cosha))
	if not rising then ha = -ha end
	return 720 - 4 * (lon + ha) - eqt + tzmin
end

-- Minutes of daylight (sunrise to sunset) on day `doy`.
function p._dayLength(lat, lon, doy)
	local rise, why = p._sunEvent(lat, lon, doy, 0, SUNRISE, true)
	if not rise then return why == 'always' and 1440 or 0 end
	return p._sunEvent(lat, lon, doy, 0, SUNRISE, false) - rise
end

local function clock(mins)
	mins = round(mins) % 1440
	return string.format('%02d:%02d', math.floor(mins / 60), mins % 60)
end

local function duration(mins)
	mins = round(mins)
	return string.format('%dh%02d', math.floor(mins / 60), mins % 60)
end

---------------------------------------------------------------------------------------------------------------
-- Local clock time (ruling M3: local clock time per country). Standard offset in hours, the summer-time rule
-- and, for a country spanning several zones, a function of the point. A summer-time rule lists the months whose
-- 15th falls inside summer time.

local DST = {
	eu = { 4, 5, 6, 7, 8, 9, 10 },     -- last Sunday of March to last Sunday of October
	us = { 3, 4, 5, 6, 7, 8, 9, 10 },  -- second Sunday of March to first Sunday of November
	au = { 10, 11, 12, 1, 2, 3 },      -- first Sunday of October to first Sunday of April
	cl = { 9, 10, 11, 12, 1, 2, 3 },   -- first Saturday of September to first Saturday of April
	eg = { 5, 6, 7, 8, 9, 10 },        -- last Friday of April to last Thursday of October
}

local function band(lon, cuts)
	-- cuts: { {maxLon, offset}, …, {nil, offset} }
	for _, c in ipairs(cuts) do
		if c[1] == nil or lon < c[1] then return c[2] end
	end
end

local TZ = {
	['Afghanistan'] = 4.5, ['Albania'] = { 1, 'eu' }, ['Algeria'] = 1, ['Angola'] = 1, ['Armenia'] = 4,
	['Austria'] = { 1, 'eu' }, ['Azerbaijan'] = 4, ['Bangladesh'] = 6, ['Belize'] = -6, ['Benin'] = 1,
	['Bhutan'] = 6, ['Bolivia'] = -4, ['Botswana'] = 2, ['Bulgaria'] = { 2, 'eu' }, ['Burkina Faso'] = 0,
	['Burundi'] = 2, ['Cambodia'] = 7, ['Cameroon'] = 1, ['Central African Republic'] = 1, ['Chad'] = 1,
	['China'] = 8, ['Colombia'] = -5, ['Costa Rica'] = -6, ['Croatia'] = { 1, 'eu' }, ['Cuba'] = -5,
	['Cyprus'] = { 2, 'eu' }, ['Czech Republic'] = { 1, 'eu' }, ['Denmark'] = { 1, 'eu' },
	['Dominican Republic'] = -4, ['Ecuador'] = -5, ['Egypt'] = { 2, 'eg' }, ['El Salvador'] = -6,
	['Eritrea'] = 3, ['Eswatini'] = 2, ['Ethiopia'] = 3, ['Fiji'] = 12, ['Finland'] = { 2, 'eu' },
	['France'] = { 1, 'eu' }, ['Gabon'] = 1, ['Gambia'] = 0, ['Georgia (country)'] = 4, ['Georgia'] = 4,
	['Germany'] = { 1, 'eu' }, ['Ghana'] = 0, ['Greece'] = { 2, 'eu' }, ['Guatemala'] = -6, ['Guinea'] = 0,
	['Guyana'] = -4, ['Haiti'] = -5, ['Honduras'] = -6, ['Hungary'] = { 1, 'eu' }, ['India'] = 5.5,
	['Iran'] = 3.5, ['Iraq'] = 3, ['Italy'] = { 1, 'eu' }, ['Ivory Coast'] = 0, ['Jamaica'] = -5,
	['Japan'] = 9, ['Jordan'] = 3, ['Kazakhstan'] = 5, ['Kenya'] = 3, ['Korea'] = 9, ['South Korea'] = 9,
	['North Korea'] = 9, ['Kyrgyzstan'] = 6, ['Laos'] = 7, ['Lebanon'] = { 2, 'eu' }, ['Lesotho'] = 2,
	['Liberia'] = 0, ['Libya'] = 2, ['Madagascar'] = 3, ['Malawi'] = 2, ['Malaysia'] = 8, ['Mali'] = 0,
	['Mauritius'] = 4, ['Morocco'] = 1, ['Mozambique'] = 2, ['Myanmar'] = 6.5, ['Namibia'] = 2,
	['Nepal'] = 5.75, ['Netherlands'] = { 1, 'eu' }, ['New Caledonia'] = 11, ['Nicaragua'] = -6, ['Niger'] = 1,
	['Nigeria'] = 1, ['Oman'] = 4, ['Pakistan'] = 5, ['Panama'] = -5, ['Paraguay'] = -3, ['Peru'] = -5,
	['Philippines'] = 8, ['Poland'] = { 1, 'eu' }, ['Portugal'] = { 0, 'eu' }, ['Republic of the Congo'] = 1,
	['Réunion'] = 4, ['Romania'] = { 2, 'eu' }, ['Rwanda'] = 2, ['São Tomé and Príncipe'] = 0,
	['Saudi Arabia'] = 3, ['Senegal'] = 0, ['Serbia'] = { 1, 'eu' }, ['Sierra Leone'] = 0, ['Somalia'] = 3,
	['South Africa'] = 2, ['South Sudan'] = 2, ['Spain'] = { 1, 'eu' }, ['Sri Lanka'] = 5.5, ['Sudan'] = 2,
	['Sweden'] = { 1, 'eu' }, ['Switzerland'] = { 1, 'eu' }, ['Syria'] = 3, ['Taiwan'] = 8, ['Tajikistan'] = 5,
	['Tanzania'] = 3, ['Thailand'] = 7, ['Togo'] = 0, ['Trinidad and Tobago'] = -4, ['Tunisia'] = 1,
	['Turkey'] = 3, ['Turkmenistan'] = 5, ['Uganda'] = 3, ['Ukraine'] = { 2, 'eu' },
	['United Kingdom'] = { 0, 'eu' }, ['Uruguay'] = -3, ['Uzbekistan'] = 5, ['Venezuela'] = -4,
	['Vietnam'] = 7, ['Yemen'] = 3, ['Zambia'] = 2, ['Zimbabwe'] = 2,
	-- Countries spanning several zones: by longitude (and latitude where a border runs east–west). The cuts are
	-- drawn so that every point on the wiki on 9 November 2026 falls in its province's zone; near a zone border
	-- elsewhere they are approximate.
	['Russia'] = function(lat, lon)
		return band(lon, { { 23, 2 }, { 48, 3 }, { 55, 4 }, { 70, 5 }, { 78, 6 }, { 99, 7 }, { 112, 8 },
			{ 130, 9 }, { 141.5, 10 }, { 160, 11 }, { nil, 12 } })
	end,
	['Mongolia'] = function(lat, lon) return lon < 99 and 7 or 8 end,
	['Democratic Republic of the Congo'] = function(lat, lon)
		return lon < ((lat > -2.5) and 24 or 20.5) and 1 or 2
	end,
	['Indonesia'] = function(lat, lon) return band(lon, { { 114.4, 7 }, { 127, 8 }, { nil, 9 } }) end,
	['Papua New Guinea'] = function(lat, lon) return lon > 154 and 11 or 10 end,
	['Brazil'] = function(lat, lon) return band(lon, { { -67.3, -5 }, { -54, -4 }, { nil, -3 } }) end,
	['Chile'] = function(lat, lon) return { -4, 'cl' } end,
	['Australia'] = function(lat, lon)
		if lon < 129 then return 8 end                                       -- Western Australia
		if lon < 138 then return lat < -26 and { 9.5, 'au' } or 9.5 end      -- South Australia / Northern Territory
		if lon < 141 then return lat < -26 and { 9.5, 'au' } or 10 end       -- South Australia / Queensland
		if lat > -28.2 or (lat > -29 and lon < 151) then return 10 end        -- Queensland: no summer time
		return { 10, 'au' }                                                   -- New South Wales, Victoria, Tasmania
	end,
	['United States'] = function(lat, lon)
		return { band(lon, { { -114.5, -8 }, { -101, -7 }, { -86.5, -6 }, { nil, -5 } }), 'us' }
	end,
	['Canada'] = function(lat, lon)
		return { band(lon, { { -120, -8 }, { -110, -7 }, { -90, -6 }, { -64, -5 }, { -59.5, -4 }, { nil, -3.5 } }), 'us' }
	end,
	['Mexico'] = function(lat, lon)
		if lon < -114.8 then return { -8, 'us' } end
		if lon < -106 then return -7 end
		if lon > -89.3 and lat < 22 then return -5 end
		return -6
	end,
}
p.TZ = TZ

local NOTES = { ['Morocco'] = 'UTC+0 during Ramadan, not shown' }

local function offsetText(h)
	if h == 0 then return 'UTC' end
	local sign = h > 0 and '+' or MINUS
	h = math.abs(h)
	local whole = math.floor(h)
	local mins = round((h - whole) * 60)
	return 'UTC' .. sign .. whole .. (mins > 0 and string.format(':%02d', mins) or '')
end

local function monthsText(list)
	return MONTHS[list[1]] .. '–' .. MONTHS[list[#list]]
end

-- The clock of a place: { offsets = {12 numbers, hours}, label = 'UTC+1, UTC+2 Apr–Oct', country = … } or nil
-- when no listed country is known.
function p._clock(countries, lat, lon)
	for _, c in ipairs(countries) do
		local z = TZ[c]
		if type(z) == 'function' then z = z(lat, lon) end
		if z ~= nil then
			local std, rule = z, nil
			if type(z) == 'table' then std, rule = z[1], z[2] end
			local offs = {}
			for m = 1, 12 do offs[m] = std end
			local label = offsetText(std)
			if rule then
				for _, m in ipairs(DST[rule]) do offs[m] = std + 1 end
				label = label .. ', ' .. offsetText(std + 1) .. ' ' .. monthsText(DST[rule])
			end
			if NOTES[c] then label = label .. ' (' .. NOTES[c] .. ')' end
			return { offsets = offs, label = label, country = c }
		end
	end
	return nil
end

-- Countries as written in an infobox: "India", "[[India]]", "Kazakhstan, Kyrgyzstan".
function p._countries(s)
	local out = {}
	s = trim(s)
	if not s then return out end
	for part in mw.text.gsplit(s, '[,;]') do
		local c = bare(part)
		if c then out[#out + 1] = c end
	end
	return out
end

---------------------------------------------------------------------------------------------------------------
-- The field cycle. A cycle field holds a month name, optionally with Early, Mid or Late; those are drawn as
-- thirds of a month. The year is 36 thirds, 0 = early January.

local MONTH_NUM = {}
for i, m in ipairs(MONTH_FULL) do
	MONTH_NUM[m:lower()] = i
	MONTH_NUM[m:sub(1, 3):lower()] = i
end
MONTH_NUM['sept'] = 9
local PART = { early = 0, mid = 1, middle = 1, late = 2 }

-- { m = 1…12, part = 0|1|2|nil } or nil.
function p._parseMonth(s)
	s = trim(s)
	if not s then return nil end
	s = mw.ustring.lower(s):gsub('%.', ''):gsub('%-', ' ')
	local words = mw.text.split(mw.text.trim(s), '%s+')
	local part, month
	if #words == 1 then
		month = MONTH_NUM[words[1]]
	elseif #words == 2 then
		part, month = PART[words[1]], MONTH_NUM[words[2]]
		if part == nil then return nil end
	end
	if not month then return nil end
	return { m = month, part = part }
end

local function third(v, isEnd)
	local base = (v.m - 1) * 3
	if v.part then return base + v.part end
	return isEnd and base + 2 or base
end

local CYCLE_FIELDS = { 'earliest_planting', 'latest_planting', 'harvest_start', 'harvest_end' }
local RANK = { [''] = 0, grow = 1, plant = 2, harvest = 3 }

-- Mark thirds a…b (inclusive, wrapping round the year) with state s where s outranks what is there.
local function mark(state, a, b, s)
	local k = a
	for _ = 1, 36 do
		if RANK[s] > RANK[state[k % 36]] then state[k % 36] = s end
		if k % 36 == b % 36 then break end
		k = k + 1
	end
end

-- One cycle (a table with the four fields, raw text) into `state`. Returns true when it marked anything.
local function addCycle(state, c)
	local p1, p2 = p._parseMonth(c.earliest_planting), p._parseMonth(c.latest_planting)
	local h1, h2 = p._parseMonth(c.harvest_start), p._parseMonth(c.harvest_end)
	local pa, pb, ha, hb
	if p1 or p2 then pa, pb = third(p1 or p2, false), third(p2 or p1, true) end
	if h1 or h2 then ha, hb = third(h1 or h2, false), third(h2 or h1, true) end
	if pa and ha then
		local a, b = (pb + 1) % 36, (ha - 1) % 36
		if a ~= ha then mark(state, a, b, 'grow') end
	end
	if pa then mark(state, pa, pb, 'plant') end
	if ha then mark(state, ha, hb, 'harvest') end
	return (pa or ha) ~= nil
end

-- 36 states ('' / 'grow' / 'plant' / 'harvest') from a list of cycles; harvest outranks planting, planting
-- outranks growing, so several accessions combine into the months any of them records.
function p._band(cycles)
	local state = {}
	for k = 0, 35 do state[k] = '' end
	local any = false
	for _, c in ipairs(cycles) do
		if addCycle(state, c) then any = true end
	end
	return state, any
end

-- The cycles of one accession from its template arguments (cycle_1_earliest_planting …).
function p._argCycles(args)
	local out = {}
	for n = 1, 2 do
		local c, has = {}, false
		for _, f in ipairs(CYCLE_FIELDS) do
			c[f] = trim(args['cycle_' .. n .. '_' .. f])
			if c[f] then has = true end
		end
		if has then out[#out + 1] = c end
	end
	return out
end

local function lowerFirst(s)
	return (s:gsub('^(%a)(%a*)', function(a, b)
		if b:match('^%l') or b == '' then return a:lower() .. b end
		return a .. b
	end))
end

local function rangeText(a, b)
	a, b = trim(a), trim(b)
	if a and b and a ~= b then
		local a2 = PART[(a:match('^(%a+)%s') or ''):lower()] and lowerFirst(a) or a
		local b2 = PART[(b:match('^(%a+)%s') or ''):lower()] and lowerFirst(b) or b
		return a2 .. '–' .. b2
	end
	local s = a or b
	return PART[(s:match('^(%a+)%s') or ''):lower()] and lowerFirst(s) or s
end

-- "planting March–May, harvest September–December"
function p._cycleText(c)
	local parts = {}
	if c.earliest_planting or c.latest_planting then
		parts[#parts + 1] = 'planting ' .. rangeText(c.earliest_planting, c.latest_planting)
	end
	if c.harvest_start or c.harvest_end then
		parts[#parts + 1] = 'harvest ' .. rangeText(c.harvest_start, c.harvest_end)
	end
	return table.concat(parts, ', ')
end

local function cyclesText(cycles)
	if #cycles == 1 then return p._cycleText(cycles[1]) end
	local t = {}
	for i, c in ipairs(cycles) do
		t[#t + 1] = (i == 1 and 'first cycle ' or 'second cycle ') .. p._cycleText(c)
	end
	return table.concat(t, '; ')
end

local function signature(cycles)
	local t = {}
	for _, c in ipairs(cycles) do
		for _, f in ipairs(CYCLE_FIELDS) do t[#t + 1] = c[f] or '' end
	end
	return table.concat(t, '|')
end

local function plantingRecorded(cycles)
	for _, c in ipairs(cycles) do
		if c.earliest_planting or c.latest_planting then return true end
	end
	return false
end

---------------------------------------------------------------------------------------------------------------
-- Linked accessions (rulings M2, M4): one SMW query per page, run through the parser.

local ASK_LABELS = {}
for n = 1, 2 do
	for i, f in ipairs(CYCLE_FIELDS) do ASK_LABELS['c' .. n .. 'f' .. i] = { n, f } end
end

local function askText(prop, title)
	local q = { '{{#ask:[[Category:Accessions]][[', prop, '::', title, ']]' }
	for n = 1, 2 do
		for i, f in ipairs(CYCLE_FIELDS) do
			q[#q + 1] = '|?Has cycle ' .. n .. ' ' .. f:gsub('_', ' ') .. '=c' .. n .. 'f' .. i
		end
	end
	q[#q + 1] = '|format=plainlist|headers=plain|link=none|sep=@@R@@|propsep=@@P@@|valuesep=@@V@@'
		.. '|limit=500|searchlabel=|default=|sort=|order=}}'
	return table.concat(q)
end
p._askText = askText

-- Parse the plainlist output into { { title = …, cycles = {…} }, … }, sorted by title.
function p._parseAsk(out)
	local rows = {}
	out = trim(out)
	if not out then return rows end
	for row in mw.text.gsplit(out, '@@R@@', true) do
		row = trim(row)
		if row then
			local title, props = row:match('^(.-) %((c[12]f[1-4]: .*)%)$')
			title = title or row
			local cyc = { {}, {} }
			if props then
				for pv in mw.text.gsplit(props, '@@P@@', true) do
					local lab, val = pv:match('^(c[12]f[1-4]): (.*)$')
					if lab then
						val = mw.text.split(val, '@@V@@', true)[1]
						local nf = ASK_LABELS[lab]
						cyc[nf[1]][nf[2]] = trim(mw.text.decode(val))
					end
				end
			end
			local cycles = {}
			for n = 1, 2 do
				if next(cyc[n]) then cycles[#cycles + 1] = cyc[n] end
			end
			rows[#rows + 1] = { title = trim(mw.text.decode(title)), cycles = cycles }
		end
	end
	table.sort(rows, function(a, b) return a.title < b.title end)
	return rows
end

-- The accessions linked to a page through `prop`. `stub` (tests) maps "prop::title" to the plainlist text.
local function linked(frame, prop, title, stub)
	if not title then return {} end
	local out
	if stub then
		out = stub[prop .. '::' .. title] or ''
	else
		out = frame:preprocess(askText(prop, title))
	end
	return p._parseAsk(out)
end

---------------------------------------------------------------------------------------------------------------
-- Climate sampling (ruling M4, M5): an appellation is sampled at the distinct climate cells holding its linked
-- accessions, each cell counted once; with none, its whole area. Accessions use their own cell; growing areas
-- their whole area.

local VARS = { 'tmax', 'tmin', 'pr', 'vpd', 'rsds' }

local function cellKey(n)
	local t = {}
	for _, v in ipairs(VARS) do
		for m = 1, 12 do t[#t + 1] = tostring(n[v][m]) end
	end
	return table.concat(t, ',')
end

-- { normals = {tmax = {…}, …}, cells = n, accessions = n, elo = m, ehi = m } or nil.
function p._cellSample(rows)
	local seen, cells, nacc = {}, {}, 0
	for _, r in ipairs(rows) do
		local a = CP._accession(r.title)
		if a then
			nacc = nacc + 1
			local k = cellKey(a)
			if not seen[k] then
				seen[k] = true
				cells[#cells + 1] = a
			end
		end
	end
	if #cells == 0 then return nil end
	local nm = {}
	for _, v in ipairs(VARS) do
		nm[v] = {}
		for m = 1, 12 do
			local s = 0
			for _, c in ipairs(cells) do s = s + c[v][m] end
			-- three decimals, so that the order of the sum never tips a displayed rounding
			nm[v][m] = round(s / #cells, 3)
		end
	end
	local lo, hi
	for _, c in ipairs(cells) do
		lo = (lo == nil or c.e < lo) and c.e or lo
		hi = (hi == nil or c.e > hi) and c.e or hi
	end
	return { normals = nm, cells = #cells, accessions = nacc, elo = lo, ehi = hi }
end

---------------------------------------------------------------------------------------------------------------
-- Cells. One absolute colour scale across every place. A cell gives its intensity as how far the lightness moves
-- (--lw-yr-t, 0 to 0.2), its chroma and its hue; the stylesheet builds the oklch colour from a light base that
-- darkens, or a dark base that lightens in dark mode.

local function clamp(t) return math.max(0, math.min(1, t)) end

local function cell(text, hue, chroma, t, title, v)
	t = clamp(t)
	local c = round(chroma * (0.25 + 0.75 * t), 3)
	local td = mw.html.create('td')
		:attr('style', string.format('--lw-yr-t:%.3f;--lw-yr-c:%s;--lw-yr-h:%s', 0.2 * t, tostring(c), tostring(hue)))
		:attr('title', title)
		:wikitext(text)
	if v ~= nil then td:attr('data-v', tostring(v)) end
	return td
end

local function blank(title)
	return mw.html.create('td'):addClass('lw-yr__na'):attr('title', title):wikitext('—')
end

local function tempCell(v, label)
	local t = (v + 15) / 50
	local hue = 255 - 230 * clamp(t)
	local strength = math.abs(t - 0.5) * 2
	return cell(fnum('%.0f', v), round(hue), 0.16, 0.25 + 0.75 * strength,
		label .. ' ' .. fnum('%.1f', v) .. ' °C', round(v, 1))
end

---------------------------------------------------------------------------------------------------------------
-- The strip.

local function fmtInt(n)
	return mw.language.getContentLanguage():formatNum(math.floor(n + 0.5))
end

local function count(n, one, many)
	if n == 1 then return 'one ' .. one end
	return n .. ' ' .. many
end

-- Builds every row. `s` = { lat, lon, normals, clock, band } → table of { key, label, unit, cls, cells }.
function p._rows(s)
	local R = {}
	local function row(key, label, unit, cls)
		local r = { key = key, label = label, unit = unit, cls = cls, cells = {} }
		R[#R + 1] = r
		return r
	end
	local first, rise, set, last = row('first', 'First light'), row('sunrise', 'Sunrise'), row('sunset', 'Sunset'),
		row('last', 'Last light')
	local len = row('daylight', 'Daylight', 'h', 'strong')
	local chg = row('change', 'Change', 'min/day')
	local hi, lo, sw = row('tmax', 'Avg daily high', '°C', 'strong'), row('tmin', 'Avg daily low', '°C', 'strong'),
		row('swing', 'Swing', '°C')
	local pr, vpd, dli = row('pr', 'Rain', 'mm', 'strong'), row('vpd', 'VPD', 'kPa'), row('dli', 'Sunlight', 'mol/m²/d')
	local lat, lon, nm = s.lat, s.lon, s.normals
	for m = 1, 12 do
		local doy = DOY1[m] + 14
		local tz = s.clock.offsets[m] * 60
		local mon = MONTH_FULL[m] .. ' 15'
		local L = p._dayLength(lat, lon, doy)
		local lt = (L / 60 - 10) / 4.5
		local fl, flw = p._sunEvent(lat, lon, doy, tz, CIVIL, true)
		local sr, srw = p._sunEvent(lat, lon, doy, tz, SUNRISE, true)
		local ss = p._sunEvent(lat, lon, doy, tz, SUNRISE, false)
		local ll = p._sunEvent(lat, lon, doy, tz, CIVIL, false)
		if fl then
			first.cells[m] = cell(clock(fl), 75, 0.05, lt * 0.6, mon .. ': first light (civil twilight) ' .. clock(fl), clock(fl))
			last.cells[m] = cell(clock(ll), 75, 0.05, lt * 0.6, mon .. ': last light (civil twilight) ' .. clock(ll), clock(ll))
		else
			local why = flw == 'always' and 'twilight lasts all night' or 'the sun stays more than 6° below the horizon'
			first.cells[m] = blank(mon .. ': ' .. why)
			last.cells[m] = blank(mon .. ': ' .. why)
		end
		if sr then
			rise.cells[m] = cell(clock(sr), 85, 0.09, lt, mon .. ': sunrise ' .. clock(sr), clock(sr))
			set.cells[m] = cell(clock(ss), 85, 0.09, lt, mon .. ': sunset ' .. clock(ss), clock(ss))
		else
			local why = srw == 'always' and 'the sun does not set' or 'the sun does not rise'
			rise.cells[m] = blank(mon .. ': ' .. why)
			set.cells[m] = blank(mon .. ': ' .. why)
		end
		len.cells[m] = cell(duration(L), 90, 0.14, lt, mon .. ': ' .. string.format('%.2f', L / 60) .. ' hours of daylight',
			round(L / 60, 3))
		local c = (p._dayLength(lat, lon, doy + 1) - p._dayLength(lat, lon, doy - 1)) / 2
		local sign = c > 0.05 and '+' or (c < -0.05 and MINUS or '±')
		chg.cells[m] = cell(sign .. string.format('%.1f', math.abs(c)), c > 0 and 150 or 300, 0.12, math.abs(c) / 2.5,
			mon .. ': day length changing ' .. (c >= 0 and '+' or MINUS) .. string.format('%.2f', math.abs(c)) .. ' min per day',
			round(c, 2))
		local h, l = nm.tmax[m], nm.tmin[m]
		hi.cells[m] = tempCell(h, MONTH_FULL[m] .. ' average daily high')
		lo.cells[m] = tempCell(l, MONTH_FULL[m] .. ' average daily low')
		sw.cells[m] = cell(fnum('%.0f', h - l), 25, 0.06, (h - l - 4) / 14,
			MONTH_FULL[m] .. ': day–night swing ' .. fnum('%.1f', h - l) .. ' °C', round(h - l, 1))
		local r = nm.pr[m]
		pr.cells[m] = cell(string.format('%.0f', r), 240, 0.15, math.log10(math.max(r, 1)) / math.log10(500),
			MONTH_FULL[m] .. ': ' .. string.format('%.1f', r) .. ' mm of rain', round(r, 1))
		local v = nm.vpd[m] / 1000
		vpd.cells[m] = cell(string.format('%.1f', v), 345, 0.13, v / 2.0,
			MONTH_FULL[m] .. ': VPD ' .. string.format('%.2f', v) .. ' kPa', round(v, 3))
		local swr = nm.rsds[m]
		local d = swr * 2.04
		dli.cells[m] = cell(string.format('%.0f', d), 100, 0.15, (d - 15) / 45,
			MONTH_FULL[m] .. ': ≈' .. string.format('%.0f', d) .. ' mol/m²/day (shortwave '
				.. string.format('%.1f', swr) .. ' MJ/m²/day × 2.04)', round(d, 1))
	end
	return R
end

local GROUPS = { first = 'Day and night', tmax = 'Temperature', pr = 'Water and light' }
local BAND_NAMES = { plant = 'planting', grow = 'growing', harvest = 'harvest', [''] = 'not recorded' }

local function bandRow(state)
	local tr = mw.html.create('tr'):addClass('lw-yr__cyc'):attr('data-row', 'cycle')
	tr:tag('th'):attr('scope', 'row'):wikitext('Cycle')
	for m = 1, 12 do
		local td = tr:tag('td'):addClass('lw-yr__band')
		local names = {}
		for k = 0, 2 do
			local s = state[(m - 1) * 3 + k]
			td:tag('span'):addClass('lw-yr__b'):addClass('lw-yr__b--' .. (s ~= '' and s or 'none'))
			names[#names + 1] = BAND_NAMES[s]
		end
		td:attr('title', MONTH_FULL[m] .. ' (early, mid, late): ' .. table.concat(names, ', '))
		td:attr('data-v', table.concat(names, ';'))
	end
	return tr
end

local function keyList()
	local ul = mw.html.create('ul'):addClass('lw-yr__key')
	for _, k in ipairs({ { 'plant', 'Planting' }, { 'grow', 'Growing' }, { 'harvest', 'Harvest' },
		{ 'none', 'Not recorded / off season' } }) do
		ul:tag('li'):tag('span'):addClass('lw-yr__b'):addClass('lw-yr__b--' .. k[1]):done():wikitext(k[2])
	end
	return ul
end

local function latlon(lat, lon)
	return string.format('%.2f° %s, %.2f° %s', math.abs(lat), lat >= 0 and 'N' or 'S', math.abs(lon),
		lon >= 0 and 'E' or 'W')
end

-- Renders the strip from a resolved sample:
-- { key, kind, lat, lon, normals, clock, koppen, elevation (text), band, any (bool), note (text), collapsed }
function p._render(s)
	local root = mw.html.create('div'):addClass('lw-yr'):attr('data-place', s.key)
	if s.collapsed then root:addClass('mw-collapsible') end
	local bar = root:tag('div'):addClass('lw-yr__bar')
	bar:tag('span'):addClass('lw-yr__title'):wikitext('Year at a glance')
	bar:tag('span'):addClass('lw-yr__computed'):wikitext(' (computed)')
	local body = root:tag('div'):addClass('lw-yr__body')
	if s.collapsed then body:addClass('mw-collapsible-content') end
	local facts = body:tag('dl'):addClass('lw-yr__facts')
	local function fact(dt, dd)
		local d = facts:tag('div')
		d:tag('dt'):wikitext(dt)
		d:tag('dd'):wikitext(dd)
	end
	fact('Point', latlon(s.lat, s.lon))
	fact('Elevation used', s.elevation)
	if s.koppen then fact('Köppen class', s.koppen) end
	fact('Clock', s.clock.label)
	local scroll = body:tag('div'):addClass('lw-yr__scroll')
	local tbl = scroll:tag('table'):addClass('lw-yr__table')
	local head = tbl:tag('tr'):addClass('lw-yr__head')
	head:tag('th'):attr('scope', 'col'):addClass('lw-yr__corner'):tag('span'):addClass('lw-yr__sr'):wikitext('Month')
	for m = 1, 12 do head:tag('th'):attr('scope', 'col'):attr('data-month', m):wikitext(MONTHS[m]) end
	for _, r in ipairs(p._rows(s)) do
		if GROUPS[r.key] then
			tbl:tag('tr'):addClass('lw-yr__grp'):tag('th'):attr('colspan', 13):attr('scope', 'rowgroup'):wikitext(GROUPS[r.key])
		end
		local tr = tbl:tag('tr'):attr('data-row', r.key)
		if r.cls then tr:addClass('lw-yr__' .. r.cls) end
		if r.unit then tr:attr('data-unit', r.unit) end
		local th = tr:tag('th'):attr('scope', 'row'):wikitext(r.label)
		if r.unit then th:tag('span'):addClass('lw-yr__u'):wikitext(r.unit) end
		for m = 1, 12 do tr:node(r.cells[m]) end
	end
	tbl:tag('tr'):addClass('lw-yr__grp'):tag('th'):attr('colspan', 13):attr('scope', 'rowgroup'):wikitext('Field cycle')
	tbl:node(bandRow(s.band))
	body:node(keyList())
	body:tag('p'):addClass('lw-yr__note'):wikitext(s.note)
	body:tag('p'):addClass('lw-yr__src'):wikitext('Day and night computed from the sun\'s position on the 15th of each month; '
		.. 'climate normals 1981–2010 from CHELSA V2.1 · [[' .. HELP .. '|About these figures]]')
	return tostring(root)
end

-- Resolve everything a page's strip needs. `a` = arguments (key, kind, country, growing_area, cycle fields,
-- collapsed); `frame` runs the SMW queries; `stub` replaces them in tests. nil when there is nothing to show.
function p._sample(a, frame, stub)
	local key = bare(a.key)
	if not key then return nil end
	local kind = mw.ustring.lower(trim(a.kind) or 'accession')
	local s = { key = key, kind = kind, collapsed = trim(a.collapsed) == 'yes' }
	local cycles, note
	if kind == 'accession' then
		local acc = CP._accession(key)
		if not acc then return nil end
		s.lat, s.lon, s.normals = acc.lat, acc.lon, acc
		s.koppen = acc.k ~= '' and acc.k or nil
		s.elevation = fmtInt(acc.e) .. ' m <span class="lw-yr__muted">computed, '
			.. ((CP._meta().elevation or {})[acc.es] or acc.es) .. ', at this point</span>'
		cycles = p._argCycles(a)
		if #cycles > 0 then
			note = 'From this accession\'s page: ' .. cyclesText(cycles) .. '.'
			if not plantingRecorded(cycles) then note = note .. ' No planting is recorded.' end
		else
			note = 'No field cycle is recorded on this accession\'s page.'
		end
	else
		local e = CP._profile(key)
		if not e then return nil end
		local nm = CP._normals(key)
		if not nm then return nil end
		s.lat, s.lon = e.lat, e.lon
		s.koppen = e.k ~= '' and e.k or nil
		local where = kind == 'appellation' and 'this appellation' or 'this growing area'
		local prop = kind == 'appellation' and 'Has appellation' or 'Has growing area'
		local rows = linked(frame, prop, key, stub)
		-- climate
		local cs = kind == 'appellation' and p._cellSample(rows) or nil
		local dem = (CP._meta().elevation or {})[e.es] or e.es
		if cs then
			s.normals = cs.normals
			local range = cs.elo == cs.ehi and (fmtInt(cs.elo) .. ' m') or (fmtInt(cs.elo) .. '–' .. fmtInt(cs.ehi) .. ' m')
			s.elevation = range .. ' <span class="lw-yr__muted">computed, '
				.. (cs.cells == 1 and ('the climate cell holding its ' .. count(cs.accessions, 'accession', 'accessions'))
					or ('the ' .. cs.cells .. ' climate cells holding its ' .. count(cs.accessions, 'accession', 'accessions')
						.. ', averaged'))
				.. '</span>'
		else
			s.normals = nm
			local range = e.e10 == e.e90 and (fmtInt(e.e) .. ' m')
				or (fmtInt(e.e) .. ' m (' .. fmtInt(e.e10) .. '–' .. fmtInt(e.e90) .. ' m)')
			s.elevation = range .. ' <span class="lw-yr__muted">computed, ' .. dem .. ', its whole area</span>'
		end
		-- cycle (M2)
		local function withCycles(rs)
			local out, sigs, nsig = {}, {}, 0
			for _, r in ipairs(rs) do
				if #r.cycles > 0 then
					out[#out + 1] = r
					local g = signature(r.cycles)
					if not sigs[g] then sigs[g] = true; nsig = nsig + 1 end
				end
			end
			return out, nsig
		end
		local function describe(rs, nsig, intro)
			local all = {}
			for _, r in ipairs(rs) do
				for _, c in ipairs(r.cycles) do all[#all + 1] = c end
			end
			local t
			if nsig == 1 then
				local agree = #rs == 1 and ': ' or (#rs == 2 and '; both give ' or ('; all ' .. #rs .. ' give '))
				t = intro .. agree .. cyclesText(rs[1].cycles) .. '.'
			else
				t = intro .. '. Their cycles differ; the band shows every part of a month any of them records.'
			end
			if not plantingRecorded(all) then
				t = t .. (#rs == 1 and ' It records no planting.' or (#rs == 2 and ' Neither records planting.'
					or ' None records planting.'))
			end
			return all, t
		end
		local withc, nsig = withCycles(rows)
		local nacc = #rows
		if #withc > 0 then
			cycles, note = describe(withc, nsig, 'From the ' .. count(#withc, 'accession', 'accessions') .. ' in ' .. where
				.. ' that ' .. (#withc == 1 and 'records' or 'record') .. ' a cycle')
		elseif kind == 'appellation' and bare(a.growing_area) then
			local ga = bare(a.growing_area)
			local grows = linked(frame, 'Has growing area', ga, stub)
			local gwith, gsig = withCycles(grows)
			local lead = (nacc == 0 and 'No accession is linked to this appellation.'
				or ('None of the ' .. count(nacc, 'accession', 'accessions') .. ' linked to this appellation records a cycle.'))
			if #gwith > 0 then
				local intro = ' The band uses the ' .. count(#gwith, 'accession', 'accessions') .. ' in the [[' .. ga .. ']] growing area that '
					.. (#gwith == 1 and 'records' or 'record') .. ' one'
				cycles, note = describe(gwith, gsig, intro)
				note = lead .. note
			else
				cycles, note = {}, lead .. ' No accession in the [[' .. ga .. ']] growing area records one either.'
			end
		else
			cycles = {}
			note = nacc == 0 and ('No accession is linked to ' .. where .. ', so no field cycle is shown.')
				or ('None of the ' .. count(nacc, 'accession', 'accessions') .. ' in ' .. where .. ' records a field cycle.')
		end
	end
	s.band, s.any = p._band(cycles)
	s.note = note
	local countries = p._countries(a.country)
	s.clock = p._clock(countries, s.lat, s.lon)
	if not s.clock then
		local h = round(s.lon / 15)
		local offs = {}
		for m = 1, 12 do offs[m] = h end
		s.clock = { offsets = offs, label = offsetText(h) .. ' (nominal, from longitude)' }
	end
	return s
end

-- {{Year at a glance}}: arguments come from the template call.
function p.strip(frame)
	local parent = frame:getParent()
	local a = {}
	for k, v in pairs(parent and parent.args or {}) do a[k] = v end
	for k, v in pairs(frame.args) do a[k] = v end
	if not trim(a.key) then a.key = mw.title.getCurrentTitle().text end
	local s = p._sample(a, frame)
	if not s then return '' end
	return frame:extensionTag('templatestyles', '', { src = STYLES }) .. p._render(s)
end

-- "yes" when the page would show a strip (for a heading or a wrapper), else empty.
function p.has(frame)
	local parent = frame:getParent()
	local args = parent and parent.args or {}
	local key = bare(frame.args.key) or bare(args.key) or mw.title.getCurrentTitle().text
	local kind = mw.ustring.lower(trim(frame.args.kind) or trim(args.kind) or 'accession')
	if kind == 'accession' then return CP._accession(key) and 'yes' or '' end
	return (CP._profile(key) and CP._normals(key)) and 'yes' or ''
end

return p