Module:WhiskerWiki growth
Appearance
Documentation for this module may be created at Module:WhiskerWiki growth/doc
-- Module:WhiskerWiki growth
--[[
Renders WhiskerWiki's own growth numbers from JSON:WhiskerWiki_growth, which the
R pipeline refreshes weekly (scripts/12_wiki_growth.R). What each count means
is defined once, in helper_functions/00_wiki_growth_helper_functions.R, and
copied into the JSON's "definitions" block -- this module only displays.
p.card the "WhiskerWiki at a glance" box for the Main Page
(Template:WhiskerWiki growth card)
p.show numbers and blocks for the growth page (Template:WhiskerWiki growth)
p.bar_chart one bar per year, scaled to the tallest; shared by the card's
mini chart and the growth page
Colors are --ww-chart-* tokens in MediaWiki:Common.css (light + dark).
Ported from HopperWiki's Module:HopperWiki growth -- same JSON shape and
blocks; the figures, labels and themes are WhiskerWiki's (species, higher
taxa, identification guides, places; images, audio recordings, documents).
--]]
local json = require("Module:JSON pipeline")
local p = {}
local PAGE = "JSON:WhiskerWiki_growth"
local DEFAULT_LINK = "WhiskerWiki:Growth"
local SITE = "WhiskerWiki"
-- The small mark after the card title (like HopperWiki's). nil = no logo.
local LOGO = "File:WhiskerWiki logo no text.png"
local MONTHS = { "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December" }
-- json.load() hands numbers back as strings; everything numeric goes through here.
local function num(x)
return tonumber(x) or 0
end
local function fmt_number(n)
if n == nil then return "—" end
local s = tostring(math.floor(num(n) + 0.5)):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
return s -- one value only: gsub's second return (the match count) would leak into callers' argument lists
end
-- "2022-11" -> "November 2022"
local function month_name(period)
local y, m = tostring(period or ""):match("^(%d%d%d%d)%-(%d%d)")
if not y then return nil end
return (MONTHS[tonumber(m)] or "") .. " " .. y
end
local function list_of(data, key)
return type(data[key]) == "table" and data[key] or {}
end
local function is_true(x)
return x == true or x == "true"
end
--[[
Year-end rows (the latest row of each year), oldest first:
{ {year = 2022, row = {...}}, ... }. The current year's entry is its latest row.
--]]
function p.yearly_rows(history)
local by_year, years = {}, {}
for _, row in ipairs(history or {}) do
local y = tonumber(tostring(row.period or ""):sub(1, 4))
if y then
if not by_year[y] then table.insert(years, y) end
if not by_year[y] or row.period > by_year[y].period then by_year[y] = row end
end
end
table.sort(years)
local out = {}
for _, y in ipairs(years) do table.insert(out, { year = y, row = by_year[y] }) end
return out
end
-- How much `measure` grew in `current`'s calendar year: current minus the last row of any earlier year.
-- Returns delta, year -- or nil when there's no earlier year to compare with.
local function this_year_delta(history, current, measure)
local year = tostring(current.period or ""):sub(1, 4)
local base
for _, row in ipairs(history or {}) do
if tostring(row.period):sub(1, 4) < year and (not base or row.period > base.period) then base = row end
end
if not base or current[measure] == nil or base[measure] == nil then return nil end
return num(current[measure]) - num(base[measure]), year
end
--[[
Vertical bars, one per yearly row, scaled to the tallest.
rows from p.yearly_rows()
measure a history field, e.g. "articles"
opts noun word used in each bar's hover text ("articles")
show_values print the value above each bar
class extra class on the wrapper (size variants)
--]]
function p.bar_chart(rows, measure, opts)
opts = opts or {}
local max = 0
for _, r in ipairs(rows) do max = math.max(max, num(r.row[measure])) end
if max == 0 then return "" end
local bars = {}
for _, r in ipairs(rows) do
local v = num(r.row[measure])
local height = math.max(2, math.floor(v / max * 100 + 0.5)) -- a zero year still shows a sliver
table.insert(bars, string.format(
'<div class="ww-growth-bar-col" title="%s: %s %s">' ..
'<div class="ww-growth-bar-value">%s</div>' ..
'<div class="ww-growth-bar-track"><div class="ww-growth-bar" style="height:%d%%;"></div></div>' ..
'<div class="ww-growth-bar-year">%s</div>' ..
'</div>',
r.year, fmt_number(v), mw.text.encode(opts.noun or measure),
opts.show_values and fmt_number(v) or "",
height, r.year
))
end
return '<div class="ww-growth-bars' .. (opts.class and (" " .. opts.class) or "") .. '">' ..
table.concat(bars) .. '</div>'
end
local function cell(label, value, note)
return string.format(
'<div class="ib-h-cell"><div class="ib-h-label">%s</div><div class="ib-h-value ww-growth-value">%s</div>%s</div>',
label, value, note and ('<div class="ww-growth-note">' .. note .. '</div>') or ""
)
end
--[[
The range-map strip: what's behind the generated state and county pages (data.geography). "" when the JSON
has no geography block (the pipeline machine lacked the shapefile outputs that week).
--]]
local function map_strip(data)
local g = list_of(data, "geography")
if not g.range_maps then return "" end
local function stat_html(value, label)
return string.format('<span class="ww-growth-map-stat"><b>%s</b> %s</span>', fmt_number(value), label)
end
return '<div class="ww-growth-map">' ..
'<div class="ww-growth-caption"><span>Mapped from hand-curated range maps</span></div>' ..
'<div class="ww-growth-map-stats">' ..
stat_html(g.range_maps, "species range maps") ..
stat_html(g.counties_mapped, "counties in " .. fmt_number(g.states_mapped) .. " states") ..
stat_html(g.species_county_records, "species–county records") ..
'</div>' ..
'<div class="ww-growth-map-note">Every range is intersected with every state and county boundary, ' ..
'and every place is linked to Wikidata.</div>' ..
'</div>'
end
--[[
The Main Page card. Optional args (direct or from the calling template):
link page the "See how we've grown" link points to (default DEFAULT_LINK)
--]]
function p.card(frame)
local args = frame.args or {}
local parent = frame:getParent() and frame:getParent().args or {}
-- An empty link= (e.g. forwarded by a proxy template like Template:Wwg) means "use the default".
local function given(x) return x ~= nil and mw.text.trim(x) ~= "" and x or nil end
local link = given(args.link) or given(parent.link) or DEFAULT_LINK
local data, err = json.load(PAGE)
if not data then
return '<span class="error">' .. SITE .. ' growth data unavailable: ' .. tostring(err) .. '</span>'
end
local current = list_of(data, "current")
local catalog = list_of(data, "catalog")
local history = list_of(data, "history")
local guides = list_of(list_of(data, "themes"), "guides")
local function grew(measure)
local delta, year = this_year_delta(history, current, measure)
if delta and delta > 0 then return "+" .. fmt_number(delta) .. " in " .. year end
return nil
end
-- The link only appears once its page exists, so the card never shows a red link while that page is unwritten.
local target = mw.title.new(link)
local link_html = (target and target.exists)
and string.format('<span class="ww-growth-link">[[%s|See how we\'ve grown →]]</span>', link)
or ""
local logo_html = LOGO
and string.format('<span class="ww-growth-logo">[[%s|x20px|link=WhiskerWiki:About|alt=%s logo]]</span>', LOGO, SITE)
or ""
local header = string.format(
'<div class="ib-h-header">' ..
'<span class="ib-h-badge">WW</span>' ..
'<span class="ww-growth-title">%s</span>' ..
'%s%s' ..
'</div>',
data.online_since and ("Growing since " .. month_name(data.online_since)) or (SITE .. " at a glance"),
logo_html, link_html
)
local cells = table.concat({
cell("Articles", fmt_number(current.articles), grew("articles")),
cell("Species write-ups", fmt_number(current.species_write_ups), grew("species_write_ups")),
cell("Species covered", fmt_number(catalog.species_pages)),
cell("Photos & images", fmt_number(current.images), grew("images")),
cell("Identification guides", fmt_number(guides.articles)),
cell("Audio recordings", fmt_number(current.audio), grew("audio")),
})
local chart = p.bar_chart(p.yearly_rows(history), "articles", { noun = "articles", show_values = true })
local html = string.format(
'<div class="ib-h ww-growth-box">' ..
'%s' ..
'<div class="ib-h-cells ib-h-cols-3">%s</div>' ..
'%s' ..
'<div class="ww-growth-chart">' ..
'<div class="ww-growth-caption"><span>Articles by year</span><span>Updated %s</span></div>' ..
'%s' ..
'</div>' ..
'</div>',
header, cells, map_strip(data), mw.text.encode(data.last_updated or "unknown"), chart
)
return frame:preprocess(html)
end
--[[ ==========================================================================
WhiskerWiki:Growth page blocks -- {{WhiskerWiki growth|<what>}} -> p.show
============================================================================ ]]
-- "2026-09-10T17:45:42Z" -> "September 10, 2026" (month = true -> "September 2026")
local function date_text(stamp, month_only)
local y, m, d = tostring(stamp or ""):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)")
if not y then
y, m = tostring(stamp or ""):match("^(%d%d%d%d)%-(%d%d)")
if not y then return "" end
month_only = true
end
local month = MONTHS[tonumber(m)] or ""
if month_only then return month .. " " .. y end
return string.format("%s %d, %s", month, tonumber(d), y)
end
local function ordinal(n)
local s = fmt_number(n)
local last2, last = n % 100, n % 10
if last2 >= 11 and last2 <= 13 then return s .. "th" end
return s .. (({ "st", "nd", "rd" })[last] or "th")
end
-- Articles sorted oldest first by the date they qualified (undated ones left out).
local function dated_articles(data, filter)
local out = {}
for _, a in ipairs(list_of(data, "articles")) do
if a.since and a.since ~= "" and (not filter or filter(a)) then table.insert(out, a) end
end
table.sort(out, function(x, y) return x.since < y.since end)
return out
end
---------------------------------------------------------------------------
-- Inline numbers: {{WhiskerWiki growth|articles}}, {{WhiskerWiki growth|total_words|floor=10000}}
---------------------------------------------------------------------------
local STAT_ALIASES = { species = "species_pages", guides = "guide_pages", counties = "county_pages", states = "state_pages" }
-- Every figure the JSON is meant to carry, so a missing one can be told apart from a typo.
local KNOWN_FIGURES = {
articles = true, species_write_ups = true, documentation_pages = true, images = true, audio = true,
documents = true, contributors = true, total_words = true,
species_pages = true, higher_taxon_pages = true, guide_pages = true, state_pages = true, county_pages = true,
geography_pages = true,
species_articles = true, higher_taxa_articles = true, guides_articles = true, geography_articles = true,
range_maps = true, states_mapped = true, counties_mapped = true, species_county_records = true,
median_species_per_county = true,
}
-- Whole months from the first month online (online_since) to the last update, or nil.
local function months_online(data)
local y0, m0 = tostring(data.online_since or ""):match("^(%d%d%d%d)%-(%d%d)")
local y1, m1 = tostring(data.last_updated or ""):match("^(%d%d%d%d)%-(%d%d)")
if not y0 or not y1 then return nil end
return (tonumber(y1) - tonumber(y0)) * 12 + (tonumber(m1) - tonumber(m0))
end
local NUMBER_WORDS = { "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
"eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen",
"eighteen", "nineteen", "twenty" }
-- 47 months -> "three and a half years": rounded DOWN to the half year, so it never overstates.
-- Lowercase, for mid-sentence; wrap in {{ucfirst:...}} to start a sentence.
local function age_in_words(months)
local halves = math.floor(months / 6)
local years, half = math.floor(halves / 2), halves % 2 == 1
if years == 0 then return half and "half a year" or "less than half a year" end
if years == 1 then return half and "a year and a half" or "one year" end
local n = NUMBER_WORDS[years] or tostring(years)
return n .. (half and " and a half years" or " years")
end
local function stat(data, key, args)
key = STAT_ALIASES[key] or key
local current, catalog = list_of(data, "current"), list_of(data, "catalog")
if key == "last_updated" then return date_text(data.last_updated) end
if key == "years_online" or key == "age" then
local months = months_online(data)
if not months then return "" end
if key == "age" then return age_in_words(months) end
return tostring(math.floor(months / 12))
end
if not KNOWN_FIGURES[key] then
return '<span class="error">Unknown ' .. SITE .. ' growth figure: ' .. mw.text.encode(key) .. '</span>'
end
local theme = key:match("^(.+)_articles$")
local theme_row = theme and list_of(list_of(data, "themes"), theme) or {}
local value = current[key] or catalog[key] or list_of(data, "geography")[key] or data[key] or theme_row.articles
if value == nil then
return '<span class="error">' .. mw.text.encode(key) .. ' is not in ' .. PAGE ..
' yet -- rerun scripts/12_wiki_growth.R with WW_GROWTH_FORCE=1</span>'
end
local v = num(value)
local floor_to = tonumber(args.floor)
if floor_to and floor_to > 0 then v = math.floor(v / floor_to) * floor_to end
return fmt_number(v)
end
---------------------------------------------------------------------------
-- charts: one small yearly chart per measure, with its total and this year's growth
---------------------------------------------------------------------------
local CHARTS = {
{ measure = "articles", title = "Articles", noun = "articles" },
{ measure = "species_write_ups", title = "Species write-ups", noun = "species write-ups" },
{ measure = "images", title = "Photos & images", noun = "images" },
{ measure = "audio", title = "Audio recordings", noun = "audio recordings" },
}
local function charts(data)
local history, current = list_of(data, "history"), list_of(data, "current")
local rows = p.yearly_rows(history)
local out = {}
for _, c in ipairs(CHARTS) do
local delta, year = this_year_delta(history, current, c.measure)
local note = (delta and delta > 0) and ('<div class="ww-growth-note">+' .. fmt_number(delta) .. ' in ' .. year .. '</div>') or ""
table.insert(out, string.format(
'<div class="ww-growth-panel">' ..
'<div class="ib-h-label">%s</div>' ..
'<div class="ib-h-value ww-growth-value">%s</div>%s' ..
'%s' ..
'</div>',
mw.text.encode(c.title), fmt_number(current[c.measure]), note,
p.bar_chart(rows, c.measure, { noun = c.noun, show_values = true })
))
end
return '<div class="ww-growth-panels">' .. table.concat(out) .. '</div>'
end
---------------------------------------------------------------------------
-- milestones: generated from the dated article list (exact day + which article) and the monthly history
---------------------------------------------------------------------------
local MILESTONES = {
{ measure = "articles", from = "articles", noun = "article", at = { 1, 25, 50, 100, 150, 200, 250, 300, 400, 500, 750, 1000 } },
{ measure = "species_write_ups", from = "articles", noun = "species write-up", at = { 1, 10, 25, 50, 75, 100, 150, 200, 300, 500 },
filter = function(a) return is_true(a.species) end },
{ measure = "guides", from = "articles", noun = "identification guide", at = { 1, 5, 10, 25, 50, 100 },
filter = function(a) return is_true(a.guide) end },
{ measure = "images", from = "history", noun = "image", at = { 1, 100, 500, 1000, 2000, 3000, 5000, 7500, 10000 } },
{ measure = "audio", from = "history", noun = "audio recording", at = { 1, 10, 25, 50, 100, 250, 500 } },
}
local function milestones(data)
local events = {}
for _, m in ipairs(MILESTONES) do
if m.from == "articles" then
local list = dated_articles(data, m.filter)
for _, k in ipairs(m.at) do
local a = list[k]
if a then
local what = (k == 1) and ("First " .. m.noun) or (ordinal(k) .. " " .. m.noun)
table.insert(events, { sort = a.since, when = date_text(a.since), what = what, title = a.title })
end
end
else
local history = list_of(data, "history")
for _, k in ipairs(m.at) do
for _, row in ipairs(history) do -- history is oldest first
if num(row[m.measure]) >= k then
local what = (k == 1) and ("First " .. m.noun) or (ordinal(k) .. " " .. m.noun)
table.insert(events, { sort = row.period, when = date_text(row.period, true), what = what })
break
end
end
end
end
end
if #events == 0 then return "" end
table.sort(events, function(x, y) return x.sort < y.sort end)
-- One page reaching two milestones at once (the first article was also the first species write-up) is one line.
local merged, by_page = {}, {}
for _, e in ipairs(events) do
local key = e.title and (e.sort .. "|" .. e.title)
if key and by_page[key] then
by_page[key].what = by_page[key].what .. " and " .. e.what:gsub("^First", "first")
else
table.insert(merged, e)
if key then by_page[key] = e end
end
end
local items = {}
for _, e in ipairs(merged) do
local text = e.title and string.format("%s: [[%s]]", e.what, e.title) or e.what
table.insert(items, string.format(
'<li><span class="ww-growth-when">%s</span><span class="ww-growth-what">%s</span></li>', e.when, text))
end
return '<ul class="ww-growth-milestones">' .. table.concat(items) .. '</ul>'
end
---------------------------------------------------------------------------
-- recent: newest articles, tagged species / guide
-- n = how many articles to list (default 8)
---------------------------------------------------------------------------
local function recent(data, args)
local n = tonumber(args.n) or 8
local all = dated_articles(data)
local items = {}
for i = #all, math.max(1, #all - n + 1), -1 do
local a = all[i]
local tag = is_true(a.species) and ' <span class="ww-growth-tag">species</span>'
or is_true(a.guide) and ' <span class="ww-growth-tag">guide</span>'
or ""
table.insert(items, string.format(
'<li><span class="ww-growth-when">%s</span><span class="ww-growth-what">[[%s]]%s</span></li>',
date_text(a.since), a.title, tag))
end
return '<ul class="ww-growth-recent">' .. table.concat(items) .. '</ul>'
end
---------------------------------------------------------------------------
-- almost there: pages 50-99 words long, species first, each with a progress bar toward 100 words
-- n = max pages per group (default 10)
---------------------------------------------------------------------------
local ARTICLE_MIN_WORDS = 100 -- mirrors GROWTH_ARTICLE_MIN_WORDS in the R helper
local function almost_there(data, args)
local n = tonumber(args.n) or 10
local species, other = {}, {}
for _, row in ipairs(list_of(data, "almost_there")) do
table.insert(is_true(row.species) and species or other, row)
end
local function group(title, rows)
if #rows == 0 then return "" end
local items = {}
for i = 1, math.min(#rows, n) do
local words = num(rows[i].words)
local to_go = math.max(0, ARTICLE_MIN_WORDS - words)
table.insert(items, string.format(
'<li><span class="ww-growth-what">[[%s]]</span>' ..
'<span class="ww-growth-progress" title="%d of %d words"><span style="width:%d%%;"></span></span>' ..
'<span class="ww-growth-togo">%d word%s to go</span></li>',
rows[i].title, words, ARTICLE_MIN_WORDS, math.floor(words / ARTICLE_MIN_WORDS * 100), to_go, to_go == 1 and "" or "s"))
end
return '<div class="ww-growth-almost-group"><div class="ib-h-label">' .. title .. '</div>' ..
'<ul class="ww-growth-almost">' .. table.concat(items) .. '</ul></div>'
end
return group("Species", species) .. group("Other pages", other)
end
---------------------------------------------------------------------------
-- catalog: what WhiskerWiki covers, as a cell grid like the card
---------------------------------------------------------------------------
local CATALOG = {
{ key = "species_pages", label = "Species" },
{ key = "higher_taxon_pages", label = "Higher taxa" },
{ key = "guide_pages", label = "Identification guides" },
{ key = "state_pages", label = "States" },
{ key = "county_pages", label = "Counties" },
}
local function catalog(data)
local c = list_of(data, "catalog")
local cells = {}
for _, item in ipairs(CATALOG) do
if c[item.key] then table.insert(cells, cell(item.label, fmt_number(c[item.key]))) end
end
return '<div class="ib-h ww-growth-box"><div class="ib-h-cells ib-h-cols-3">' .. table.concat(cells) .. '</div></div>'
end
---------------------------------------------------------------------------
-- definitions: the rules behind every number, straight from the JSON
---------------------------------------------------------------------------
-- Only the numbers the card and growth page show; documents, contributors and documentation pages are still
-- defined in the JSON for anyone using them inline.
local DEFINITIONS = {
{ key = "articles", label = "Article" },
{ key = "species_write_ups", label = "Species write-up" },
{ key = "images", label = "Photos & images" },
{ key = "audio", label = "Audio recordings" },
{ key = "geography", label = "States and counties" },
{ key = "reconstructed", label = "Reconstructed history" },
{ key = "snapshot", label = "Weekly snapshots" },
}
local function definitions(data)
local defs = list_of(data, "definitions")
local items = {}
for _, d in ipairs(DEFINITIONS) do
if defs[d.key] then table.insert(items, string.format("<li><b>%s:</b> %s</li>", mw.text.encode(d.label), mw.text.encode(defs[d.key]))) end
end
return '<ul class="ww-growth-definitions">' .. table.concat(items) .. '</ul>'
end
---------------------------------------------------------------------------
-- themes: for each main page family, how many pages exist and how many are written up as articles
---------------------------------------------------------------------------
-- Only the families people write up. Places (states, counties) are still in the JSON's themes, but every one is
-- generated by the pipeline, so its bar would sit at 0% forever.
local THEMES = {
{ key = "species", label = "Species" },
{ key = "higher_taxa", label = "Higher taxa" },
{ key = "guides", label = "Guides" },
}
local function percent(part, whole)
if whole <= 0 then return "0%" end
local pct = part / whole * 100
if pct > 0 and pct < 10 then return string.format("%.1f%%", pct) end
return string.format("%d%%", math.floor(pct + 0.5))
end
local function themes(data)
local t = list_of(data, "themes")
local rows = {}
for _, th in ipairs(THEMES) do
local row = t[th.key]
if type(row) == "table" then
local pages, articles = num(row.pages), num(row.articles)
local width = pages > 0 and math.max(1, math.floor(articles / pages * 100 + 0.5)) or 0
table.insert(rows, string.format(
'<li>' ..
'<span class="ww-growth-theme">%s</span>' ..
'<span class="ww-growth-theme-count"><b>%s</b> of %s pages written up</span>' ..
'<span class="ww-growth-progress" title="%s of %s"><span style="width:%d%%;"></span></span>' ..
'<span class="ww-growth-togo">%s</span>' ..
'</li>',
th.label, fmt_number(articles), fmt_number(pages), fmt_number(articles), fmt_number(pages),
width, percent(articles, pages)
))
end
end
if #rows == 0 then return "" end
return '<ul class="ww-growth-almost ww-growth-themes">' .. table.concat(rows) .. '</ul>'
end
local function geography(data)
local strip = map_strip(data)
if strip == "" then return "" end
return '<div class="ib-h ww-growth-box">' .. strip .. '</div>'
end
local BLOCKS = {
geography = geography, charts = charts, milestones = milestones, recent = recent,
["almost there"] = almost_there, almost_there = almost_there, catalog = catalog, definitions = definitions,
themes = themes,
}
--[[
{{WhiskerWiki growth|<what>|...}}
<what> = charts | geography | milestones | recent | almost there | themes | catalog | definitions | card
= any figure: articles, species_write_ups, documentation_pages, images, audio, documents, contributors,
total_words, species_pages (or species), higher_taxon_pages, guide_pages (or guides),
state_pages (or states), county_pages (or counties), geography_pages, last_updated, years_online,
age ("three and a half years"; {{ucfirst:...}} to start a sentence),
species_articles, higher_taxa_articles, guides_articles, geography_articles,
range_maps, states_mapped, counties_mapped, species_county_records, median_species_per_county
floor=N round a figure down to a multiple of N ("over 50,000 words")
n= see recent / almost there
--]]
function p.show(frame)
local args = {}
for k, v in pairs(frame:getParent() and frame:getParent().args or {}) do args[k] = v end
for k, v in pairs(frame.args or {}) do args[k] = v end
local what = mw.text.trim(tostring(args[1] or ""))
if what == "card" then return p.card(frame) end
local data, err = json.load(PAGE)
if not data then
return '<span class="error">' .. SITE .. ' growth data unavailable: ' .. tostring(err) .. '</span>'
end
local block = BLOCKS[what]
if block then return frame:preprocess(block(data, args)) end
return stat(data, what, args)
end
function p.data()
return json.load(PAGE)
end
return p