Module:Swatches

From Dune: Awakening Community Wiki
Jump to navigation Jump to search

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):
Vehicle
Weapon
Garment
Placeable
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:
Sandbike
Flamethrowers
Heavy Armor
heading (optional)
integer
Heading level for the generated heading. Defaults to 3, i.e. h3.
Must be an integer between 1 and 6. 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:
true
yes
1

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 scopes and onlyClasses fields.
  • class is matched case-insensitively against class names within onlyClasses tokens 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; pass expanded=yes to 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