Module:Swatches

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

Module:Swatches

Lists cosmetic swatches from Module:Swatches/data as infoboxes.

This module outputs its content inside a collapsible container, which is collapsed by default (mw-collapsible mw-collapsed).

When expanded, the swatch infobox cards are laid out using an inner flex container configured for responsive wrapping and horizontal overflow (display:flex; flex-wrap:wrap; overflow-x:auto; align-items:flex-start; gap:1em;).

Usage

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>}}

Render swatches expanded

By default, the swatch list is collapsed. To render it expanded, pass a truthy value to the expanded parameter.

{{#invoke:Swatches|list|expanded=yes}}

Parameters

1 (optional)
string
Compatibility scope to filter swatches.
If omitted, all swatches are shown.
If an invalid value is specified, no swatches are shown.
Valid values (case-insensitive, singular or plural):
Vehicle
Weapon
Garment
Placeable
2 (optional)
string
Name of a class within the specified scope, to further filter swatch compatibility.
{{PAGENAME}} can be used if the page title matches the dataset naming.
Examples (case-insensitive):
Sandbike
Flamethrowers
Heavy Armor
expanded (optional)
boolean
If specified with a truthy value, output will be expanded by default.
Valid values:
true
yes
1

Notes

  • 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.



local p = {}

local data = require("Module:Swatches/data")

-- ---------- Helpers ----------

local function trim(s)
  return mw.text.trim(tostring(s or ""))
end

local function lowerTrim(s)
  return mw.ustring.lower(trim(s))
end

local function isTruthy(s)
  s = lowerTrim(s)
  return s == "true" or s == "yes" or s == "1"
end

local function listHasToken(list, token)
  for _, v in ipairs(list) do
    if lowerTrim(v) == token then return true end
  end

  return false
end

-- ---------- Data ----------

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 lowerTrim(a.name) < lowerTrim(b.name)
end)

local SCOPE_MAP = {
  vehicle = "Vehicles",
  weapon = "Weapons",
  garment = "Garments",
  placeable = "Placeables",
}

-- ---------- Matching ----------

local function normalizeInputScope(s)
  s = 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

  -- invalid input scope
  return nil
end

local function deriveCompatibilityDisplay(swatch)
  local scopes = swatch.scopes or { "all" }

  if listHasToken(scopes, "all") then return nil 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 listHasToken(swatchScopes, "all") then return true end

  -- Otherwise must match the requested scope
  if not listHasToken(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 _, v in ipairs(swatch.onlyClasses) do
    if lowerTrim(v):match("^" .. scope .. ":") then
      hasRestrictionForScope = true
      if lowerTrim(v) == 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 renderColorBox(color)
  if not color or color == "" then
    return mw.html.create("span"):wikitext("NULL")
  end

  local box = mw.html.create("div")
    :addClass("copyable")
    :css({
      width = "38px",
      height = "38px",
      ["background-color"] = color,
      display = "inline-block",
      border = "1px solid #000"
    })

  -- copyable color code
  box:tag("span")
    :attr("data-value", color)
    :addClass("copy-text")

  -- tooltip anchor
  box:tag("span")
    :attr("aria-hidden", "true")
    :addClass("tooltiptext")
    :wikitext("Copy to clipboard")    

  return box
end

local function renderCompatibilityHTML(swatch)
  local labels = deriveCompatibilityDisplay(swatch)

  if not labels then
    return mw.html.create("span"):wikitext("All")
  end

  if #labels == 1
    then return mw.html.create("span"):wikitext(labels[1])
  end

  local ul = mw.html.create("ul")
  for _, v in ipairs(labels) do
    ul:tag("li"):wikitext(v)
  end

  return ul
end

local function renderInfoboxHTML(swatch)
  local infobox = mw.html.create("table")
    :addClass("infobox")

  -- title
  local trAbove = infobox:tag("tr")
  local thAbove = trAbove:tag("th")
    :attr("colspan", "2")
    :addClass("infobox-above")

  if swatch.link and swatch.link ~= "" then
    thAbove:wikitext(string.format("[[%s|%s]]", swatch.link, swatch.name))
  else
    thAbove:wikitext(swatch.name)
  end

  -- image
  local trImage = infobox:tag("tr")
  local tdImage = trImage:tag("td")
    :attr("colspan", "2")
    :addClass("infobox-image")

  local image = string.format(
    "[[File:%s|200px|center|link=",
    swatch.image or "Placeholder.jpg"
  )

  if swatch.link and swatch.link ~= "" then
    image = image .. swatch.link
  end

  tdImage:wikitext(image .. "]]")

  -- color boxes
  local trColors = infobox:tag("tr")
  local tdColors = trColors:tag("td")
    :attr("colspan", "2")
    :addClass("infobox-full-data")

  local div = tdColors:tag("div")
    :css("white-space", "nowrap")

  for i = 1, 4 do
    local c = swatch.colors and swatch.colors[i]
    div:node(renderColorBox(c))
  end

  -- cosmetic type
  local trType = infobox:tag("tr")

  trType:tag("th")
    :attr("scope", "row")
    :addClass("infobox-label")
    :wikitext("[[Cosmetics|Cosmetic]] Type")

  trType:tag("td")
    :addClass("infobox-data")
    :wikitext("Swatch")

  -- 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 infobox
end

function p.list(frame)
  local args = frame.args
  local scope = normalizeInputScope(args[1] or "")

  if args[1] and args[1] ~= "" and not scope then
    return string.format(
      '<span class="error">Invalid scope "%s"</span>',
      mw.text.encode(args[1])
    )
  end

  local class = lowerTrim(args[2] or "")

  local outer = mw.html.create("div")
    :addClass("mw-collapsible")

  local expanded = isTruthy(args.expanded)
  if not expanded then outer:addClass("mw-collapsed") end

  local content = outer:tag("div")
    :addClass("mw-collapsible-content")
    :css({
      display = "flex",
      ["flex-wrap"] = "wrap",
      ["overflow-x"] = "auto",
      ["align-items"] = "flex-start",
      gap = "1em"
    })

  local rendered = false
  for _, swatch in ipairs(SORTED_DATA) do
    if swatchAppliesTo(swatch, scope, class) then
      content:node(renderInfoboxHTML(swatch))
      rendered = true
    end
  end

  if not rendered then return "No matching swatches." end

  return tostring(outer)
end

return p