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.

Since 7 October 2026 every request to landrace.wiki passes through Cloudflare, a security service brought in after a denial-of-service attack made the site unreachable. The Privacy policy now explains what this means for your data.

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

-- Module:Population
-- Renders {{Infobox Population}}: the field grade worked out from its scored items, the map point,
-- the holdings reading, and every SMW value the population page stores (#set and #subobject).
-- Also the population readings shown elsewhere: on accessions (p.accession), on the appellation,
-- area, region and country infoboxes (p.spread) and on the Grow-out priorities page (p.priorities).
-- Item codes, labels, floors and bands live in the tables below and nowhere else.
-- The seed-lot and holdings rules live in Module:Grow-out priority.

local p = {}
local GOP = require('Module:Grow-out priority')

local VERSION = 'v0.1'
local LAPSE_YEARS = 10

local SETS = {
	cultivated = {
		floor = 16,
		items = {
			{ 'A1.1', 'Outer area the population is grown within' },
			{ 'A1.2', 'Area actually planted' },
			{ 'A1.3', 'Households that sow and save its seed every year' },
			{ 'A2.1', 'Loss of outer area over ten years' },
			{ 'A2.2', 'Loss of planted area over ten years' },
			{ 'A2.3', 'Year-to-year swing in planted area' },
			{ 'A2.4', 'Loss of seed-saving households over ten years' },
			{ 'A3.1', 'Share of descriptors showing two or more states' },
			{ 'A3.2', 'Share of growers who swap seed locally' },
			{ 'B1.1', 'Share of growers who say seed is plentiful' },
			{ 'B1.2', 'Share of growers with someone to take it over' },
			{ 'B1.3', 'Other local landraces given up in ten years' },
			{ 'B1.4', 'Share of the crop under modern varieties' },
			{ 'C1.1', 'Outside support for growing or selling it' },
			{ 'C1.2', 'How far its main product is sold' },
			{ 'C1.3', 'How many households use it' },
			{ 'C2.1', 'Average age of growers' },
			{ 'D1.1', 'Grown on farms with or without support' },
			{ 'D2.1', 'Share of growers farming in the traditional way' },
			{ 'D2.2', 'Share of growers using bought farm chemicals' },
			{ 'D3.1', 'Payments or pressure to switch varieties' },
			{ 'D3.2', 'Share of growers hit by floods, drought or fire' },
			{ 'E1', 'Eradication by the authorities over ten years' },
		},
	},
	feral = {
		floor = 9,
		items = {
			{ 'A1.1', 'Outer area the stands lie within' },
			{ 'A1.2f', 'Area the stands cover' },
			{ 'A1.3f', 'Plants in flower, by the scarcer sex' },
			{ 'A2.1', 'Loss of outer area over ten years' },
			{ 'A2.2f', 'Loss of stand area over ten years' },
			{ 'A2.3f', 'Year-to-year swing in stand area' },
			{ 'A2.4f', 'Loss of plants over ten years' },
			{ 'A3.1', 'Share of descriptors showing two or more states' },
			{ 'B1.4f', "Share of the stands' plants in the hybrid class" },
			{ 'F1', 'Number of locations under threat' },
			{ 'F2', 'Habitat lost or degraded over ten years' },
			{ 'F3', 'Plants cut or pulled before seed' },
			{ 'E1', 'Eradication by the authorities over ten years' },
		},
	},
}

-- Lower bound of each band, highest first; a score above 80% is Very high.
local BANDS = {
	{ 80, 'Very high', true },
	{ 65, 'High' },
	{ 50, 'Moderate' },
	{ 35, 'Low' },
	{ 20, 'Very low' },
	{ 10, 'Near threatened' },
	{ 0, 'Least concern' },
}
local DATA_DEFICIENT = 'Data deficient'

local ALLOWED = {
	basis = { seen = true, told = true },
	scoring = { field = true, regrow = true },
	reachability = { reachable = true, ['with difficulty'] = true, ['not reachable'] = true },
	field_status = { present = true, Extinct = true, ['Extinct on-farm'] = true },
	introgression = {
		['None seen or reported'] = true, ['Reported nearby'] = true,
		['Reported in this population'] = true, ['Seen in the plants'] = true,
	},
	habitat = { ['more than half the outer area'] = true, part = true, none = true },
	harvest = { ['most stands most years'] = true, some = true, none = true },
}

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

local function err(msg)
	return '<span class="error">' .. msg .. '</span>'
end

local function band(pct)
	for _, b in ipairs(BANDS) do
		if (b[3] and pct > b[1]) or (not b[3] and pct >= b[1]) then
			return b[2]
		end
	end
	return BANDS[#BANDS][2]
end

-- The grade from the item scores. Returns a table the renderer and the tests both read.
function p.grade(args, nowYear)
	local setName = trim(args.item_set) or 'cultivated'
	local set = SETS[setName]
	local out = { set = setName, errors = {}, items = {} }
	if not set then
		table.insert(out.errors, 'unknown item_set "' .. setName .. '"')
		set = SETS.cultivated
		out.set = 'cultivated'
	end
	out.total, out.floor = #set.items, set.floor
	local sum, n, earliest = 0, 0, nil
	for _, it in ipairs(set.items) do
		local code = it[1]
		local raw = trim(args[code])
		local row = { code = code, label = it[2] }
		if raw then
			local s = tonumber(raw)
			if s and s == math.floor(s) and s >= 1 and s <= 5 then
				row.score = s
				sum, n = sum + s, n + 1
			else
				table.insert(out.errors, code .. ' score "' .. raw .. '" is not a whole number from 1 to 5')
			end
		end
		row.basis = trim(args[code .. '_basis'])
		if row.basis and not ALLOWED.basis[row.basis] then
			table.insert(out.errors, code .. '_basis "' .. row.basis .. '" is not seen or told')
			row.basis = nil
		end
		row.source = trim(args[code .. '_source'])
		row.year = tonumber(trim(args[code .. '_year']) or '')
		if code == 'A3.1' then
			row.scoring = trim(args['A3.1_scoring'])
			if row.scoring and not ALLOWED.scoring[row.scoring] then
				table.insert(out.errors, 'A3.1_scoring "' .. row.scoring .. '" is not field or regrow')
				row.scoring = nil
			end
		end
		if row.score and row.year and (not earliest or row.year < earliest) then
			earliest = row.year
		end
		table.insert(out.items, row)
	end
	out.scored = n
	out.evidenceYear = tonumber(trim(args.evidence_year) or '') or earliest
	if n > 0 then
		out.pct = sum / (5 * n) * 100
	end
	if n >= set.floor then
		out.computedBand = band(out.pct)
	end
	if not out.computedBand then
		out.band = DATA_DEFICIENT
		out.reason = 'floor'
	elseif not out.evidenceYear then
		out.band = DATA_DEFICIENT
		out.reason = 'noyear'
	elseif nowYear - out.evidenceYear > LAPSE_YEARS then
		out.band = DATA_DEFICIENT
		out.reason = 'lapsed'
		out.lapsedYear = out.evidenceYear + LAPSE_YEARS
	else
		out.band = out.computedBand
		out.score = out.pct
	end
	return out
end

-- "32° 6' 43.20" N, 77° 11' 27.60" E" or "32.112, 77.191" -> lat, lon
function p.parseCoord(s)
	s = mw.text.trim((s or ''):gsub('<[^>]*>', ''))
	local a, b = s:match('^([^,]+),([^,]+)$')
	if not a then return nil end
	local function one(t)
		t = mw.text.trim(t)
		local n = tonumber(t)
		if n then return n end
		local d, m, sec, h = t:match('^(%d+)°%s*(%d+)\'%s*([%d%.]+)"%s*([NSEW])$')
		if not d then
			d, m, h = t:match('^(%d+)°%s*([%d%.]+)\'%s*([NSEW])$')
			sec = '0'
		end
		if not d then
			d, h = t:match('^([%d%.]+)°%s*([NSEW])$')
			m, sec = '0', '0'
		end
		if not d then return nil end
		local v = tonumber(d) + tonumber(m) / 60 + tonumber(sec) / 3600
		if h == 'S' or h == 'W' then v = -v end
		return v
	end
	local lat, lon = one(a), one(b)
	if lat and lon then return lat, lon end
	return nil
end

local function fmt(x)
	local s = string.format('%.4f', x):gsub('0+$', ''):gsub('%.$', '')
	return s
end

-- The map point: the centroid of the member fields' stored points, else the appellation's point.
-- A typed point is never read.
function p.point(frame, fields, appellation)
	local pts = {}
	if #fields > 0 then
		local q = '[[' .. table.concat(fields, '||') .. ']]'
		local res = frame:callParserFunction('#ask', {
			q, '?Has coordinates', mainlabel = '-', format = 'plainlist', sep = ';',
			headers = 'hide', limit = '100', searchlabel = '',
		})
		for piece in mw.text.gsplit(res or '', ';', true) do
			local lat, lon = p.parseCoord(piece)
			if lat then table.insert(pts, { lat, lon }) end
		end
	end
	if #pts > 0 then
		local la, lo = 0, 0
		for _, pt in ipairs(pts) do la, lo = la + pt[1], lo + pt[2] end
		return fmt(la / #pts) .. ', ' .. fmt(lo / #pts), 'centroid of ' .. #pts .. (#pts == 1 and ' field' or ' fields')
	end
	if appellation then
		local res = frame:callParserFunction('#show', { appellation, '?Has coordinates' })
		local lat, lon = p.parseCoord(res)
		if lat then
			return fmt(lat) .. ', ' .. fmt(lon), "the appellation's point"
		end
	end
	return nil, nil
end

local function list(s)
	local t = {}
	for v in mw.text.gsplit(s or '', ',', true) do
		v = mw.text.trim(v)
		if v ~= '' then table.insert(t, v) end
	end
	return t
end

local function linkList(t)
	local o = {}
	for _, v in ipairs(t) do table.insert(o, '[[' .. v .. ']]') end
	return table.concat(o, ', ')
end

-- numbered groups: prefix1_key, prefix2_key, ...
local function groups(args, prefix, keys)
	local t = {}
	for i = 1, 20 do
		local g, any = { i = i }, false
		for _, k in ipairs(keys) do
			g[k] = trim(args[prefix .. i .. '_' .. k])
			if g[k] then any = true end
		end
		if any then table.insert(t, g) end
	end
	return t
end

------------------------------------------------------------------------------------------
-- Holdings: the population's seed lots, read by Module:Grow-out priority
------------------------------------------------------------------------------------------

local STYLES = 'Template:Grow-out priority/styles.css'

local function stepSpan(step)
	return '<span class="gop-step gop-step--' .. step:lower():gsub('%s+', '-') .. '">' .. step .. '</span>'
end

-- The holdings reading of the population on this page. The population's own values come from
-- this page's arguments: the store still holds the copy from the last save.
function p.holdings(frame, popId, g, reach, fstatus, intro, traits)
	local data = GOP.loadPopulation(frame, popId, os.date('!%Y-%m-%d'))
	local pop = data.populations[popId] or { id = popId }
	data.populations[popId] = pop
	pop.band, pop.lapsedFrom = g.band, nil
	pop.grade_year = g.evidenceYear and tostring(g.evidenceYear) or nil
	pop.item_set, pop.reachability, pop.field_status = g.set, reach, fstatus
	pop.introgression, pop.trait_changes = {}, {}
	for _, r in ipairs(intro or {}) do table.insert(pop.introgression, { grade = r.grade, date = r.year }) end
	for _, r in ipairs(traits or {}) do table.insert(pop.trait_changes, { date = r.date }) end
	return GOP.evaluatePopulation(popId, data, {})
end

-- The reading is the method's, as computed. With no seed lot recorded it is Urgent (no verified copy
-- held); the display says beside it that no lot has been recorded on the wiki yet.
local NO_LOTS = '<small>(no seed lots recorded on the wiki yet)</small>'
local function noLots(pr) return #pr.lots == 0 end

function p.holdingsValues(pr)
	return GOP.populationValues(pr)
end

function p.holdingsText(pr)
	local h = pr.holdings
	local t = { stepSpan(h.step) .. (h.why and (' · ' .. h.why) or '') }
	table.insert(t, string.format('<br /><small>%d verified %s from %d %s; places: %s</small>',
		#h.counting, #h.counting == 1 and 'copy' or 'copies', h.nCollections,
		h.nCollections == 1 and 'collection' or 'collections', h.places or 'not known'))
	for _, c in ipairs(h.listed) do
		table.insert(t, '<br /><small>Every lot Urgent: collection [[' .. c.id .. ']]</small>')
	end
	if noLots(pr) then table.insert(t, '<br />' .. NO_LOTS) end
	return table.concat(t)
end

------------------------------------------------------------------------------------------
-- Reading stored populations (for accessions, the levels above, the priorities page)
------------------------------------------------------------------------------------------

local POP_PRINTOUTS = {
	{ 'name', 'Has population name' }, { 'appellation', 'Has appellation' },
	{ 'area', 'Has growing area' }, { 'region', 'Has growing region' }, { 'country', 'Has country' },
	{ 'band', 'Has field grade' }, { 'scored', 'Has field grade items scored' },
	{ 'set', 'Has field item set' }, { 'fstatus', 'Has field status' },
	{ 'frank', 'Has field grade rank' }, { 'hrank', 'Has holdings rank' }, { 'brank', 'Has best lot rank' },
	{ 'hstep', 'Has holdings step' }, { 'live', 'Has live verified copy' },
}
local NUMERIC = { scored = true, frank = true, hrank = true, brank = true }
local TRUE = { ['true'] = true, ['1'] = true, yes = true, t = true }
local FALSE = { ['false'] = true, ['0'] = true, no = true, f = true }

local function loadPops(frame, cond)
	local out = {}
	for _, row in ipairs(GOP._ask(frame, '[[Category:Populations]]' .. cond, POP_PRINTOUTS)) do
		local r = { id = row.subject }
		for _, po in ipairs(POP_PRINTOUTS) do
			local v = row.fields[po[1]]
			v = v and trim(v[1]) or nil
			if NUMERIC[po[1]] then v = tonumber(v or '') end
			r[po[1]] = v
		end
		if r.live then
			local l = r.live:lower()
			r.live = TRUE[l] and true or (FALSE[l] and false or nil)
		end
		r.name = r.name or r.id
		table.insert(out, r)
	end
	return out
end
p._loadPops = loadPops

local function popLink(r)
	return '[[' .. r.id .. (r.name ~= r.id and ('|' .. r.name) or '') .. ']]'
end

local function setTotal(r)
	return #(SETS[r.set or 'cultivated'] or SETS.cultivated).items
end

local function gradeText(r)
	local t = (r.band or DATA_DEFICIENT) .. ' · ' .. (r.scored or 0) .. ' of ' .. setTotal(r) .. ' items'
	if r.set == 'feral' then t = t .. ' <small>(feral stands)</small>' end
	if r.fstatus == 'Extinct' or r.fstatus == 'Extinct on-farm' then t = t .. ' · ' .. r.fstatus end
	return t
end

local function holdingsShort(r)
	if not r.hstep then return "''not read yet''" end
	return stepSpan(r.hstep) .. (r.noLots and (' ' .. NO_LOTS) or '')
end

local function count(frame, q)
	local s = (frame:callParserFunction('#ask', { q, format = 'count' }) or ''):gsub('[^%d]', '')
	return tonumber(s) or 0
end

-- Rows for a sidebar table, each followed by a row separator.
local function rowsOut()
	local o = {}
	return o, function(label, value)
		if value and value ~= '' then
			table.insert(o, '! class="lw-infobox__label" | ' .. label .. '\n| class="lw-infobox__value" | ' .. value .. '\n|-')
		end
	end, function(text)
		table.insert(o, '! colspan="2" class="lw-infobox__section-header" | ' .. text .. '\n|-')
	end
end

-- Every seed lot recorded, by population (none until seed lots are recorded: C3).
local function lotsByPopulation(frame)
	local byPop = {}
	local lots = GOP._ask(frame, '[[Is lot of::+]]', {
		{ 'acc', 'Is lot of' }, { 'holder', 'Has lot holder' }, { 'step', 'Has seed lot step' },
		{ 'factors', 'Has seed lot factors' }, { 'preservation', 'Is flagged for preservation grow-out' } }, 2000)
	if #lots == 0 then return byPop end
	local accPop = {}
	for _, row in ipairs(GOP._ask(frame, '[[Category:Accessions]][[Has population::+]]', { { 'pop', 'Has population' } }, 2000)) do
		accPop[row.subject] = row.fields.pop and trim(row.fields.pop[1])
	end
	for _, l in ipairs(lots) do
		local acc = l.fields.acc and trim(l.fields.acc[1])
		local pop = acc and accPop[acc]
		if pop then
			byPop[pop] = byPop[pop] or {}
			table.insert(byPop[pop], {
				id = l.subject, acc = acc,
				holder = l.fields.holder and trim(l.fields.holder[1]),
				urgent = l.fields.step and trim(l.fields.step[1]) == 'Urgent',
				factors = l.fields.factors or {},
				preservation = l.fields.preservation and TRUE[(trim(l.fields.preservation[1]) or ''):lower()],
			})
		end
	end
	return byPop
end

local function markLots(pops, lots)
	for _, r in ipairs(pops) do r.noLots = not lots[r.id] end
end

-- The step colours, carried inside the last cell: a style tag on a line of its own between table
-- rows would be pushed out of the table, and in front of a row it breaks the row.
local function withStyles(frame, o)
	if #o == 0 then return '' end
	local tag = frame:extensionTag('templatestyles', '', { src = STYLES })
	o[#o] = o[#o]:gsub('\n|%-$', function() return tag .. '\n|-' end)
	return table.concat(o, '\n')
end

------------------------------------------------------------------------------------------
-- Accession infobox: the population's readings, in place of the old priority badge
-- {{#invoke:Population|accession|population=|priority=}}; p.accessionPopulation gives the
-- population title alone (or nothing), for the section's show-if test.
------------------------------------------------------------------------------------------

local function accessionPop(frame)
	local a = frame.args
	local pop = trim(a.population)
	if pop then return pop end
	local page = trim(a.page) or mw.title.getCurrentTitle().prefixedText
	return trim(frame:callParserFunction('#show', { page, '?Has population', link = 'none' }))
end

function p.accessionPopulation(frame)
	return accessionPop(frame) or ''
end

function p.accession(frame)
	local o, row = rowsOut()
	local pop = accessionPop(frame)
	local priority = trim(frame.args.priority)
	if pop then
		local r = loadPops(frame, '[[' .. pop .. ']]')[1]
		if r then markLots({ r }, lotsByPopulation(frame)) end
		row('Population', r and popLink(r) or ('[[' .. pop .. ']]'))
		if r then
			row('Field grade', gradeText(r))
			row('Holdings', holdingsShort(r))
		end
		return withStyles(frame, o)
	elseif priority then
		row('Priority', '<span class="lw-status lw-status--' .. priority .. '">' .. priority .. '</span>')
	end
	return table.concat(o, '\n')
end

------------------------------------------------------------------------------------------
-- The spread on an appellation, growing area, growing region or country infobox:
-- counts of its populations per field grade band and per holdings step, never a grade of its own.
-- {{#invoke:Population|spread|level=growing area}}
------------------------------------------------------------------------------------------

local LEVELS = {
	appellation = 'Has appellation', ['growing area'] = 'Has growing area',
	['growing region'] = 'Has growing region', country = 'Has country',
}
local HOLDINGS_ORDER = { 'Urgent', 'At risk', 'Data deficient', 'Secure' }

local function tally(list, key, order)
	local n = {}
	for _, r in ipairs(list) do
		local v = r[key]
		if v then n[v] = (n[v] or 0) + 1 end
	end
	local out = {}
	for _, v in ipairs(order) do
		if n[v] then table.insert(out, n[v] .. ' ' .. v) end
	end
	return out, n
end

function p.spread(frame)
	local a = frame.args
	local level = mw.ustring.lower(trim(a.level) or '')
	local prop = LEVELS[level]
	if not prop then return err('Population spread: level "' .. level .. '" is not appellation, growing area, growing region or country.') end
	local page = trim(a.page) or mw.title.getCurrentTitle().text
	local cond = '[[' .. prop .. '::' .. page .. ']]'
	local pops = loadPops(frame, cond)
	markLots(pops, lotsByPopulation(frame))
	local o, row, header = rowsOut()
	header('Conservation')
	if #pops == 0 then
		row('Populations', "''none documented yet''")
		return table.concat(o, '\n')
	end
	-- Field grades, by band
	local bandOrder = {}
	for _, b in ipairs(BANDS) do table.insert(bandOrder, b[2]) end
	table.insert(bandOrder, DATA_DEFICIENT)
	local bands = tally(pops, 'band', bandOrder)
	local worst
	for _, r in ipairs(pops) do
		if r.frank and r.frank < GOP.RANK.field[DATA_DEFICIENT] then
			if not worst or r.frank < worst.frank or (r.frank == worst.frank and r.name < worst.name) then worst = r end
		end
	end
	local notAssessed = 0
	for _, r in ipairs(pops) do if not r.scored or r.scored == 0 then notAssessed = notAssessed + 1 end end
	local t = { #pops .. ' documented' }
	if #bands > 0 then table.insert(t, '<br />Field grade: ' .. table.concat(bands, ' · ')) end
	if worst then table.insert(t, '<br />Worst: ' .. popLink(worst) .. ' (' .. worst.band .. ')') end
	if notAssessed > 0 then table.insert(t, '<br />' .. notAssessed .. ' not yet assessed') end
	row('Populations', table.concat(t))
	-- Holdings, by step
	local steps = tally(pops, 'hstep', HOLDINGS_ORDER)
	local nolots = 0
	for _, r in ipairs(pops) do if r.noLots then nolots = nolots + 1 end end
	local h = {}
	if #steps > 0 then table.insert(h, 'Holdings: ' .. table.concat(steps, ' · ')) end
	if nolots > 0 then table.insert(h, (NO_LOTS:gsub('%(no seed lots', '(' .. nolots .. ' with no seed lots', 1))) end
	row('Holdings', table.concat(h, '<br />'))
	-- Accessions placed in a population, of all at this level
	local sub = '<q>[[Category:Populations]]' .. cond .. '</q>'
	local inPop = count(frame, '[[Category:Accessions]][[Has population::' .. sub .. ']]')
	local all = count(frame, '[[Category:Accessions]]' .. cond)
	row('In a population', inPop .. ' of ' .. all .. (all == 1 and ' accession' or ' accessions'))
	-- Seed lots of those populations, by step
	local lots = GOP._ask(frame, '[[Has seed lot step::+]][[Is lot of.Has population::' .. sub .. ']]',
		{ { 'step', 'Has seed lot step' } })
	if #lots > 0 then
		local ls = {}
		for _, l in ipairs(lots) do ls[#ls + 1] = { step = l.fields.step and trim(l.fields.step[1]) } end
		row('Seed lots', #lots .. ' (' .. table.concat(tally(ls, 'step', HOLDINGS_ORDER), ' · ') .. ')')
	end
	row('Priorities', '[[Grow-out priorities]]')
	return withStyles(frame, o)
end

------------------------------------------------------------------------------------------
-- The Grow-out priorities page: a collecting list and a grow-out list
-- {{#invoke:Population|priorities|country=|region=}}
------------------------------------------------------------------------------------------

local function byRanks(keys)
	return function(x, y)
		for _, k in ipairs(keys) do
			local a, b = x[k] or 99, y[k] or 99
			if a ~= b then return a < b end
		end
		return x.name < y.name
	end
end

local function lotCell(list)
	if not list then return '' end
	local o = {}
	for _, l in ipairs(list) do
		if l.urgent or l.preservation then
		local t = '[[' .. l.id .. '|' .. (l.holder or 'Seed lot') .. ']]'
		if l.urgent and #l.factors > 0 then t = t .. ': ' .. table.concat(l.factors, ', ') end
		if l.preservation then t = t .. ' <small>(review for a preservation grow-out)</small>' end
		table.insert(o, t)
		end
	end
	return table.concat(o, '<br />')
end

local function popTable(list, lots, withHoldings)
	if #list == 0 then return "''None.''" end
	local t = { '{| class="wikitable lw-gop-table"', '! # !! Population !! Field grade' .. (withHoldings and ' !! Holdings' or '')
		.. ' !! Country !! Growing region !! Urgent lots' }
	for i, r in ipairs(list) do
		table.insert(t, '|-\n| ' .. i .. ' || ' .. popLink(r) .. ' || ' .. gradeText(r)
			.. (withHoldings and (' || ' .. holdingsShort(r)) or '')
			.. ' || ' .. (r.country and ('[[' .. r.country .. ']]') or '')
			.. ' || ' .. (r.region and ('[[' .. r.region .. ']]') or '')
			.. ' || ' .. lotCell(lots[r.id]))
	end
	table.insert(t, '|}')
	return '<div class="lw-gop-scroll">\n' .. table.concat(t, '\n') .. '\n</div>'
end

local function mapDiv(cond)
	local q = '[[Category:Populations]][[Has coordinates::+]]' .. cond
		.. '|?Has coordinates|?Has population name|?Has holdings step|?Has holdings rank|?Has field grade'
		.. '|?Has field grade items scored|?Has field item set|?Has appellation|?Has country'
	q = mw.text.encode(q, '%[%]"<>&')
	return '<div class="lw-map lw-gop-map" data-map-type="population" data-smw-query="' .. q .. '"></div>'
end

local function filterLinks(pops, key, label)
	local n, order = {}, {}
	for _, r in ipairs(pops) do
		local v = r[key]
		if v then
			if not n[v] then table.insert(order, v); n[v] = 0 end
			n[v] = n[v] + 1
		end
	end
	table.sort(order)
	local o = {}
	for _, v in ipairs(order) do
		local sub = mw.title.new('Grow-out priorities/' .. v)
		local text = v .. ' (' .. n[v] .. ')'
		table.insert(o, (sub and sub.exists) and ('[[Grow-out priorities/' .. v .. '|' .. text .. ']]') or text)
	end
	if #o == 0 then return nil end
	return "'''" .. label .. ":''' " .. table.concat(o, ' · ')
end

function p.priorities(frame)
	local a = frame.args
	local country, region = trim(a.country), trim(a.region)
	local cond = (country and ('[[Has country::' .. country .. ']]') or '') .. (region and ('[[Has growing region::' .. region .. ']]') or '')
	local pops = loadPops(frame, cond)
	local lots = lotsByPopulation(frame)
	markLots(pops, lots)
	local NONE_FIELD = GOP.RANK.field[DATA_DEFICIENT]
	local NONE_HOLD = GOP.RANK.holdings[GOP.STEP.dd]
	local collecting, collectingApart, growout, growoutApart = {}, {}, {}, {}
	for _, r in ipairs(pops) do
		local graded = r.frank and r.frank < NONE_FIELD
		if r.live == true then
			table.insert((graded and r.hrank and r.hrank < NONE_HOLD) and growout or growoutApart, r)
		elseif r.live == false then
			table.insert(graded and collecting or collectingApart, r)
		else -- not read yet (the page has not been parsed since the module changed)
			table.insert(collectingApart, r)
		end
	end
	table.sort(collecting, byRanks({ 'frank' }))
	table.sort(collectingApart, byRanks({ 'frank' }))
	table.sort(growout, byRanks({ 'hrank', 'frank', 'brank' }))
	table.sort(growoutApart, byRanks({ 'hrank', 'frank', 'brank' }))

	local o = { frame:extensionTag('templatestyles', '', { src = STYLES }) }
	if not country and not region then
		local all = loadPops(frame, '')
		local f1, f2 = filterLinks(all, 'country', 'By country'), filterLinks(all, 'region', 'By growing region')
		if f1 or f2 then table.insert(o, '<div class="lw-gop-filters">' .. table.concat({ f1, f2 }, '<br />') .. '</div>') end
	end
	table.insert(o, mapDiv(cond))
	table.insert(o, '== Collecting list ==')
	table.insert(o, 'Populations with no live verified copy held anywhere, ranked by field grade.')
	table.insert(o, popTable(collecting, lots, true))
	if #collectingApart > 0 then
		table.insert(o, "'''Not ranked''' (no field grade that can be read):")
		table.insert(o, popTable(collectingApart, lots, true))
	end
	table.insert(o, '== Grow-out list ==')
	table.insert(o, 'Populations with at least one live verified copy, ranked by holdings, then field grade, then their best seed lot.')
	table.insert(o, popTable(growout, lots, true))
	if #growoutApart > 0 then
		table.insert(o, "'''Not ranked''' (no field grade or no holdings reading that can be read):")
		table.insert(o, popTable(growoutApart, lots, true))
	end
	table.insert(o, '<p class="lw-gop-asof">As of ' .. os.date('!%Y-%m-%d') .. ' · method ' .. VERSION .. '</p>')
	return table.concat(o, '\n\n')
end

function p.infobox(frame)
	local args = frame:getParent().args
	local errors = {}
	local page = mw.title.getCurrentTitle()
	local name = trim(args.name) or page.text
	local nowYear = tonumber(os.date('!%Y'))
	local g = p.grade(args, nowYear)
	for _, e in ipairs(g.errors) do table.insert(errors, e) end

	local appellation = trim(args.appellation)
	local fields = list(args.fields)
	local point, pointBasis = p.point(frame, fields, appellation)

	local reach = trim(args.reachability)
	if reach and not ALLOWED.reachability[reach] then
		table.insert(errors, 'reachability "' .. reach .. '" is not reachable, with difficulty or not reachable')
		reach = nil
	end
	local fstatus = trim(args.field_status)
	if fstatus and not ALLOWED.field_status[fstatus] then
		table.insert(errors, 'field_status "' .. fstatus .. '" is not present, Extinct or Extinct on-farm')
		fstatus = nil
	end
	local habitat = trim(args.habitat_loss)
	if habitat and not ALLOWED.habitat[habitat] then
		table.insert(errors, 'habitat_loss "' .. habitat .. '" is not an allowed class')
		habitat = nil
	end
	local harvest = trim(args.harvest_before_seed)
	if harvest and not ALLOWED.harvest[harvest] then
		table.insert(errors, 'harvest_before_seed "' .. harvest .. '" is not an allowed class')
		harvest = nil
	end

	local intro = groups(args, 'introgression', { 'grade', 'year', 'source' })
	for _, r in ipairs(intro) do
		if r.grade and not ALLOWED.introgression[r.grade] then
			table.insert(errors, 'introgression' .. r.i .. '_grade "' .. r.grade .. '" is not on the evidence scale')
			r.grade = nil
		end
	end
	local traits = groups(args, 'trait_change', { 'date', 'what', 'basis', 'source' })
	for _, r in ipairs(traits) do
		if r.basis and not ALLOWED.basis[r.basis] then
			table.insert(errors, 'trait_change' .. r.i .. '_basis "' .. r.basis .. '" is not seen or told')
			r.basis = nil
		end
	end
	local flowers = groups(args, 'flower_count', { 'date', 'females', 'males', 'source' })
	local past = groups(args, 'assessment', { 'year', 'band', 'items', 'set' })

	local pr = p.holdings(frame, page.prefixedText, g, reach, fstatus, intro, traits)

	-- SMW: the page's own values
	local set = {
		['Has population name'] = name,
		['Has appellation'] = appellation,
		['Has growing area'] = trim(args.growing_area),
		['Has growing region'] = trim(args.growing_region),
		['Has country'] = trim(args.country),
		['Has coordinates'] = point,
		['Has genetic group'] = trim(args.genetic_group),
		['Has reachability'] = reach,
		['Has field status'] = fstatus,
		['Has field status date'] = trim(args.field_status_date),
		['Has field item set'] = g.set,
		['Has field grade'] = g.band,
		['Has field grade score'] = g.score and string.format('%.1f', g.score) or nil,
		['Has field grade items scored'] = tostring(g.scored),
		['Has field grade evidence year'] = g.evidenceYear and tostring(g.evidenceYear) or nil,
		['Has field grade date'] = trim(args.assessed),
		['Has field grade assessor'] = trim(args.assessor),
		['Has method version'] = VERSION,
		['Has threatened locations'] = trim(args.locations),
		['Has location threat'] = trim(args.locations_threat),
		['Has habitat loss class'] = habitat,
		['Has harvest before seed class'] = harvest,
	}
	for k, v in pairs(p.holdingsValues(pr)) do set[k] = v end
	-- #set needs its values as unnamed "Property=value" arguments
	local setArgs = {}
	for k, v in pairs(set) do
		if v then table.insert(setArgs, k .. '=' .. v) end
	end
	for _, f in ipairs(fields) do
		table.insert(setArgs, 'Has field=' .. f)
	end
	table.sort(setArgs)
	frame:callParserFunction('#set', setArgs)

	for _, it in ipairs(g.items) do
		if it.score then
			frame:callParserFunction('#subobject', {
				'field-item-' .. it.code,
				['Has field item code'] = it.code,
				['Has field item score'] = tostring(it.score),
				['Has field item basis'] = it.basis,
				['Has field item source'] = it.source,
				['Has field item year'] = it.year and tostring(it.year) or nil,
				['Has field item scoring basis'] = it.scoring,
				['Has field item set'] = g.set,
			})
		end
	end
	for _, r in ipairs(past) do
		frame:callParserFunction('#subobject', {
			'field-assessment-' .. r.i,
			['@linkWith'] = 'Has field assessment',
			['Has field assessment year'] = r.year,
			['Has field assessment band'] = r.band,
			['Has field assessment items scored'] = r.items,
			['Has field item set'] = r.set,
		})
	end
	for _, r in ipairs(intro) do
		frame:callParserFunction('#subobject', {
			'introgression-' .. r.i,
			['@linkWith'] = 'Has introgression assessment',
			['Has introgression grade'] = r.grade,
			['Has introgression year'] = r.year,
			['Has introgression source'] = r.source,
		})
	end
	for _, r in ipairs(traits) do
		frame:callParserFunction('#subobject', {
			'trait-change-' .. r.i,
			['@linkWith'] = 'Has trait change report',
			['Has trait change date'] = r.date,
			['Has trait change description'] = r.what,
			['Has trait change basis'] = r.basis,
			['Has trait change source'] = r.source,
		})
	end
	for _, r in ipairs(flowers) do
		frame:callParserFunction('#subobject', {
			'flower-count-' .. r.i,
			['Has flower count date'] = r.date,
			['Has flowering female count'] = r.females,
			['Has flowering male count'] = r.males,
			['Has flower count source'] = r.source,
		})
	end

	-- Display
	local o = {}
	local function row(label, value)
		if value and value ~= '' then
			table.insert(o, '|-\n! class="lw-infobox__label" | ' .. label .. '\n| class="lw-infobox__value" | ' .. value)
		end
	end
	local function header(text)
		table.insert(o, '|-\n! colspan="2" class="lw-infobox__section-header" | ' .. text)
	end
	local function full(cls, value)
		table.insert(o, '|-\n| colspan="2" class="' .. cls .. '" | ' .. value)
	end

	table.insert(o, frame:extensionTag('templatestyles', '', { src = 'Template:Infobox Growing Area/styles.css' }))
	table.insert(o, frame:extensionTag('templatestyles', '', { src = STYLES }))
	table.insert(o, '{| class="infobox lw-infobox lw-infobox--growing-area lw-infobox--population"')
	table.insert(o, '|-\n! colspan="2" class="lw-infobox__title" | ' .. name)
	if trim(args.other_names) then
		table.insert(o, '|-\n! colspan="2" class="lw-infobox__subtitle" | ' .. args.other_names)
	end
	local image = trim(args.image)
	if image then
		full('lw-infobox__media', '[[File:' .. image .. '|320px|' .. (trim(args.image_alt) or name) .. ']]')
		if trim(args.image_caption) then full('lw-infobox__caption', args.image_caption) end
	end
	if point then
		full('lw-infobox__map-container', '<div class="lw-infobox__map-container"><div class="lw-infobox__map lw-map" data-center="'
			.. point .. '"><div class="lw-infobox__map-expand" title="Expand map">⛶</div></div></div>')
	end

	if appellation or trim(args.growing_area) or trim(args.growing_region) or trim(args.country) then
		header('Hierarchy')
		row('Appellation', appellation and '[[' .. appellation .. ']]')
		row('Growing area', trim(args.growing_area) and '[[' .. args.growing_area .. ']]')
		row('Growing region', trim(args.growing_region) and '[[' .. args.growing_region .. ']]')
		row('Country', trim(args.country) and '[[' .. mw.text.trim(args.country) .. ']]')
	end

	header('Population')
	row('Fields and stands', #fields > 0 and linkList(fields))
	row('Genetic group', trim(args.genetic_group) and ('[[' .. mw.text.trim(args.genetic_group) .. ']]'
		.. (trim(args.genetic_group_evidence) and ('<br /><small>' .. args.genetic_group_evidence .. '</small>') or '')))
	row('Reachability', reach and mw.language.getContentLanguage():ucfirst(reach))
	row('In the field', fstatus and (mw.language.getContentLanguage():ucfirst(fstatus)
		.. (trim(args.field_status_date) and (' (' .. args.field_status_date .. ')') or '')))
	row('Map point', point and (point .. ' <small>(' .. pointBasis .. ')</small>'))

	header('Conservation')
	local gradeText
	if g.reason == 'lapsed' then
		gradeText = DATA_DEFICIENT .. '<br /><small>last graded ' .. g.computedBand .. ' (' .. g.evidenceYear
			.. ' evidence), lapsed ' .. g.lapsedYear .. '</small>'
	elseif g.reason == 'noyear' then
		gradeText = DATA_DEFICIENT .. '<br /><small>evidence year not recorded</small>'
	else
		gradeText = g.band
	end
	row('Field grade', gradeText)
	row('Items scored', g.scored .. ' of ' .. g.total .. ' <small>(' .. g.floor .. ' needed)</small>')
	if g.score then row('Score', string.format('%.0f', g.score) .. '%') end
	row('Item set', g.set == 'feral' and 'Feral stands only' or 'Cultivated')
	row('Evidence', g.evidenceYear and tostring(g.evidenceYear))
	row('Assessed', trim(args.assessed) and (args.assessed .. (trim(args.assessor) and (' by ' .. args.assessor) or '')))
	row('Holdings', p.holdingsText(pr))
	row('Method', VERSION .. ' <small>(as of ' .. os.date('!%Y-%m-%d') .. ')</small>')

	-- Item table, collapsed
	local t = { '{| class="wikitable mw-collapsible mw-collapsed lw-infobox__items" style="width:100%; font-size:90%;"',
		'|+ Items', '! Item !! Score !! Seen or told !! Source' }
	for _, it in ipairs(g.items) do
		local src = it.source or ''
		if it.year and not src:find(tostring(it.year), 1, true) then src = src .. (src ~= '' and ', ' or '') .. it.year end
		if it.scoring then src = src .. (src ~= '' and '; ' or '') .. 'scored on a ' .. it.scoring end
		table.insert(t, '|-\n| <abbr title="' .. mw.text.encode(it.label) .. '">' .. it.code .. '</abbr> ' .. it.label
			.. ' || ' .. (it.score and tostring(it.score) or "''not known''")
			.. ' || ' .. (it.basis or '') .. ' || ' .. src)
	end
	table.insert(t, '|}')
	full('lw-infobox__items-container', '\n' .. table.concat(t, '\n'))

	if #past > 0 then
		local h = { '{| class="wikitable mw-collapsible mw-collapsed" style="width:100%; font-size:90%;"',
			'|+ Earlier field grades', '! Evidence !! Grade !! Items !! Set' }
		for _, r in ipairs(past) do
			table.insert(h, '|-\n| ' .. (r.year or '') .. ' || ' .. (r.band or '') .. ' || ' .. (r.items or '') .. ' || ' .. (r.set or ''))
		end
		table.insert(h, '|}')
		full('lw-infobox__items-container', '\n' .. table.concat(h, '\n'))
	end

	if #intro > 0 or #traits > 0 then
		header('Change since collection')
		for _, r in ipairs(intro) do
			row('Introgression' .. (r.year and (' (' .. r.year .. ')') or ''), (r.grade or '')
				.. (r.source and ('<br /><small>' .. r.source .. '</small>') or ''))
		end
		for _, r in ipairs(traits) do
			row('Trait change' .. (r.date and (' (' .. r.date .. ')') or ''), (r.what or '')
				.. ((r.basis or r.source) and ('<br /><small>' .. table.concat({ r.basis, r.source }, ', ') .. '</small>') or ''))
		end
	end

	if g.set == 'feral' and (#flowers > 0 or trim(args.locations) or habitat or harvest) then
		header('Feral stands')
		for _, r in ipairs(flowers) do
			row('In flower' .. (r.date and (' (' .. r.date .. ')') or ''),
				(r.females or '?') .. ' female, ' .. (r.males or '?') .. ' male'
				.. (r.source and ('<br /><small>' .. r.source .. '</small>') or ''))
		end
		row('Locations', trim(args.locations) and (args.locations
			.. (trim(args.locations_threat) and (' <small>(' .. args.locations_threat .. ')</small>') or '')))
		row('Habitat lost', habitat)
		row('Cut before seed', harvest)
	end

	table.insert(o, '|}')
	if #errors > 0 then
		table.insert(o, err('Infobox Population: ' .. table.concat(errors, '; ') .. '.'))
	end
	return table.concat(o, '\n')
end

return p