Module:Swatches
Module:Swatches
Renders cosmetic swatches defined in Module:Swatches/data.
This module provides the following function(s):
get- renders a single swatch infobox by name.list- renders a filtered list of swatch infoboxes.
Usage
Get a single swatch
Renders a single swatch infobox by name. The swatch name is case-sensitive and must match the key defined in Module:Swatches/data.
{{#invoke:Swatches|get|<NAME>}}
List all swatches
If no scope is provided, all swatches in the dataset are shown:
{{#invoke:Swatches|list}}
List swatches for a scope page
Scope can be specified to show all swatches that are compatible with at least one class in that scope:
{{#invoke:Swatches|list|<SCOPE>}}
List swatches for a class page
Class can additionally be specified to show only swatches that are compatible with that class:
{{#invoke:Swatches|list|<SCOPE>|<CLASS>}}
Set heading level
By default, the list heading renders as h3. To override this, pass a value to the heading parameter.
{{#invoke:Swatches|list|heading=2}}
Render expanded
The list may auto-collapse depending on the number of items listed. To force the section expanded regardless of count, pass a truthy value to the expanded parameter.
{{#invoke:Swatches|list|expanded=yes}}
Parameters
get
- 1
- string
- Swatch name (case-sensitive). Must match a key in Module:Swatches/data.
- Returns an error message if the swatch does not exist.
list
- 1 (optional)
- string
- Compatibility scope to filter swatches (case-insensitive).
- If omitted, all swatches are shown.
- If specified, must match a scope defined in Module:Swatches/data. An error message is returned if the value is invalid.
- Valid values (singular and plural forms both accepted):
VehicleWeaponGarmentPlaceable
- 2 (optional)
- string
- Name of a class within the specified scope, to further filter swatch compatibility (case-insensitive).
{{BASEPAGENAME}}can be used if the page title matches the dataset naming.- Examples:
SandbikeFlamethrowersHeavy Armor
- heading (optional)
- integer
- Heading level for the generated heading. Defaults to
3, i.e.h3. - Must be an integer between
1and6. Invalid or out-of-range values fall back to the default.
- expanded (optional)
- boolean
- If specified with a truthy value, output will be expanded by default.
- Valid values:
trueyes1
Notes
- This module previously used
frame:preprocess()to expand infobox template invocations.- But this carries a performance overhead, which was slowing down pages due to the number of swatches rendered at once.
- Thus, an overhaul was done to render the infoboxes directly.
list
- Swatch compatibility rules are defined in Module:Swatches/data using the optional
scopesandonlyClassesfields. classis matched case-insensitively against class names withinonlyClassestokens in Module:Swatches/data.- Output is wrapped in a shared collapsible section (see
newCollapsibleSection()in Module:Common). A count marker (Total: <INTEGER>) appears between the heading and the content and stays visible regardless of collapse state. The section auto-collapses by default once the item count exceeds 6; passexpanded=yesto override.
local p = {}
local common = require("Module:Common")
local data = require("Module:Swatches/data")
-- ---------- Data ----------
local SCOPE_MAP = {
vehicle = "Vehicles",
weapon = "Weapons",
garment = "Garments",
placeable = "Placeables",
}
local SORTED_DATA = {}
for name, swatch in pairs(data) do
swatch.name = name
SORTED_DATA[#SORTED_DATA + 1] = swatch
end
table.sort(SORTED_DATA, function(a, b)
return a.name:lower() < b.name:lower()
end)
-- ---------- Matching ----------
local function normalizeInputScope(s)
s = common.lowerTrim(s)
if s == "" then return "" end
-- match singular
if SCOPE_MAP[s] then return s end
-- match plural
if s:sub(-1) == "s" then
local singular = s:sub(1, -2)
if SCOPE_MAP[singular] then return singular end
end
return
end
local function deriveCompatibilityDisplay(swatch)
local scopes = swatch.scopes
if not scopes or #scopes == 0 then
scopes = { "all" }
end
if common.listContains(scopes, "all") then return end
local classByScope = {}
if swatch.onlyClasses then
for _, token in ipairs(swatch.onlyClasses) do
local scope, class = token:match("^([^:]+):(.+)$")
if scope and class then
classByScope[scope] = classByScope[scope] or {}
table.insert(classByScope[scope], class)
end
end
end
local labels = {}
for _, scope in ipairs(scopes) do
local classes = classByScope[scope]
if classes and #classes > 0 then
for _, class in ipairs(classes) do
labels[#labels + 1] = class
end
else
labels[#labels + 1] = "All " .. (SCOPE_MAP[scope] or scope)
end
end
table.sort(labels)
return labels
end
local function swatchAppliesTo(swatch, scope, class)
-- if no scope specified, show all swatches
if scope == "" then return true end
-- if `scopes` not specified in dataset, default to "all"
local swatchScopes = swatch.scopes or { "all" }
if common.listContains(swatchScopes, "all") then return true end
-- otherwise must match the requested scope
if not common.listContains(swatchScopes, scope) then return false end
-- if no class specified, scope match is enough
if class == "" then return true end
-- if `onlyClasses` is not present, applies to all classes in this scope
if not swatch.onlyClasses or #swatch.onlyClasses == 0 then return true end
local hasRestrictionForScope = false
local token = scope .. ":" .. class
for _, classToken in ipairs(swatch.onlyClasses) do
local normalized = common.lowerTrim(classToken)
if normalized:match("^" .. scope .. ":") then
hasRestrictionForScope = true
if normalized == token then return true end
end
end
-- if onlyClass was specified for this scope, and none matched → false
-- if there were no restrictions for this scope → true
return not hasRestrictionForScope
end
-- ---------- Rendering ----------
local function renderSwatchPreview(swatch)
local colors = swatch.colors
if not colors or #colors < 4 then
return common.error("Swatch <code>%s</code> color data is incomplete", swatch.name)
end
local wrapper = mw.html.create("div")
:addClass("swatch-preview")
:css({
position = "relative",
["max-width"] = "200px",
["aspect-ratio"] = "2 / 3",
margin = "0 auto",
border = "2px solid var(--color-text-main)",
overflow = "hidden",
})
local sections = {
"polygon(50% 80%, -75% 0%, 175% 0%)", -- top - colors[1]
"polygon(50% 80%, -75% 0%, -75% 160%)", -- left - colors[2]
"polygon(50% 80%, 175% 0%, 175% 160%)", -- right - colors[3]
"polygon(50% 80%, -75% 160%, 175% 160%)", -- bottom - colors[4]
}
local lineBase = {
position = "absolute",
left = "50%", top = "80%",
height = "2px",
width = "500px",
["background-color"] = "var(--color-text-main)",
["pointer-events"] = "none",
}
for i, clip in ipairs(sections) do
local color = colors[i]
if color == "" then
return common.error("Swatch <code>%s</code> color <code>%d</code> data is blank", swatch.name, i)
end
wrapper:tag("div")
:addClass("copyable")
:attr("aria-label", string.format("Copy color %d hex code", i))
:css({
position = "absolute",
top = "0", left = "0", width = "100%", height = "100%",
["background-color"] = color,
["clip-path"] = clip,
cursor = "pointer",
transform = "translateZ(0)",
})
:tag("span")
:attr("data-value", color)
:addClass("copy-text")
end
wrapper:tag("div"):css(lineBase):css({ transform = "translate(-50%, -50%) rotate(-44deg)" })
wrapper:tag("div"):css(lineBase):css({ transform = "translate(-50%, -50%) rotate(44deg)" })
return wrapper
end
local function renderCompatibilityHTML(swatch)
local labels = deriveCompatibilityDisplay(swatch) or {"All"}
return common.renderBulletList(labels, true)
end
local function renderInfoboxHTML(swatch)
local wrapper = mw.html.create("div")
:addClass("infobox-wrapper")
local infobox = wrapper:tag("table")
:addClass("infobox")
-- title
local trAbove = infobox:tag("tr")
local thAbove = trAbove:tag("th")
:attr("colspan", "2")
:addClass("infobox-above")
if swatch.link then
thAbove:wikitext(string.format("[[%s|%s]]", swatch.link, swatch.name))
else
thAbove:wikitext(swatch.name)
end
-- swatch preview "image"
local trPreview = infobox:tag("tr")
local tdPreview = trPreview:tag("td")
:attr("colspan", "2")
:addClass("infobox-image")
tdPreview:node(renderSwatchPreview(swatch))
-- caption
local trCaption = infobox:tag("tr")
trCaption:tag("td")
:attr("colspan", "2")
:css("font-style", "italic")
:css("text-align", "center")
:wikitext("Interactive Palette")
-- compatibility
local trCompat = infobox:tag("tr")
trCompat:tag("th")
:attr("scope", "row")
:addClass("infobox-label")
:wikitext("Compatibility")
local tdCompat = trCompat:tag("td")
:addClass("infobox-data")
tdCompat:node(renderCompatibilityHTML(swatch))
return wrapper
end
-- ---------- Interface ----------
function p.get(frame)
local key = common.trim(frame.args[1])
if key == "" then return common.error("No swatch specified") end
-- lookup swatch by key (case-sensitive)
local swatch = data[key]
if not swatch then return common.error("Unknown swatch: <code>%s</code>", key) end
return tostring(renderInfoboxHTML(swatch))
end
function p.list(frame)
local args = frame.args
local scope = normalizeInputScope(args[1])
if args[1] and args[1] ~= "" and not scope then return common.error("Unknown scope: <code>%s</code>", args[1]) end
local class = common.lowerTrim(args[2])
local filtered = {}
for _, swatch in ipairs(SORTED_DATA) do
if swatchAppliesTo(swatch, scope, class) then
filtered[#filtered + 1] = swatch
end
end
if #filtered == 0 then return common.error("No matching swatches") end
local outer, content = common.newCollapsibleSection("[[Swatches]]", #filtered, args.heading, args.expanded)
content:addClass("flex-container")
for _, swatch in ipairs(filtered) do
content:node(renderInfoboxHTML(swatch))
end
return tostring(outer)
end
return p