Module:ItemsNav

local p = {} local h = {}

local File = require("Module:File") local Franchise = require("Module:Franchise") local Term = require("Module:Term") local utilsError = require("Module:UtilsError") local utilsLayout = require("Module:UtilsLayout") local utilsMarkup = require("Module:UtilsMarkup") local utilsPage = require("Module:UtilsPage") local utilsString = require("Module:UtilsString") local utilsTable = require("Module:UtilsTable")

local data = mw.loadData("Module:Items/Data")

local classes = { stateContainer = "state-container", states = "state-container__states", state = "state-container__state", defaultState = "state-container__state state-container__state--active", controls = "state-container__controls state-container__controls--vertical", stateControlForward = "state-control state-control-forward", stateControlBack = "state-control state-control-back", stateControlDisabled = "state-control--disabled", } local defaultSpacing = "8px"

local currentPage = mw.title.getCurrentTitle.text

function p.Main(frame) local isTemplatePage = frame:getParent:getTitle == currentPage return p.printNav(frame.args[1], frame:getParent.args[1]) end

function p.printNav(game, frameDisabled) local nav = h.createNav(game, data[game]) if frameDisabled then return nav end local header = "Items in " .. Franchise.link(game) local footer = utilsMarkup.inline("Click on an item", {		caption = true,		italic = true,	}) -- maxWidth calculation is so that the table expands to accomodate the width of any resizable image maps local maxWidth = h.getMaxWidth(data[game]) if maxWidth > 0 then maxWidth = maxWidth + 64 else maxWidth = nil end return utilsLayout.table({		styles = {			["margin"] = "1em auto",			["width"] = maxWidth and "100%" or nil,			["max-width"] = maxWidth and maxWidth .."px" or nil		},		rows = {			{				header = true,				cells = {header}			},			{				cells = {nav},			},			{				footer = true,				cells = {footer},			},		},	}) end function h.getMaxWidth(tabs) local maxWidth = 0 for _, tab in ipairs(tabs) do		if tab.map then maxWidth = math.max(maxWidth, tab.map.maxWidth or 0) elseif tab.rows then maxWidth = math.max(maxWidth, tab.rows.maxWidth or 0) elseif tab.subtabs then maxWidth = math.max(maxWidth, h.getMaxWidth(tab.subtabs)) end end return maxWidth end

function h.createNav(game, data) local tabs = {} local pages = {} local defaultTab for i, tabData in ipairs(data) do		local component, componentPages = h.createNavComponent(game, tabData) tabs[i] = { label = tabData.tab, tooltip = tabData.tabCaption, content = component, }		pages = utilsTable.concat(pages, componentPages) defaultTab = defaultTab or utilsTable.keyOf(pages, currentPage) and i	end return utilsLayout.tabs(tabs, {		default = defaultTab or 1,		align = "center",		collapse = true,		fixedContentWidth = true,		columns = data.tabColumns,	}), pages end

function h.createNavComponent(game, data) if data.subtabs then return h.createNav(game, data.subtabs) end if data.map then return h.createMapNav(game, data.map) end if data.rows then return h.createRowsNav(game, data.rows) end end

function h.createRowsNav(game, data) local html = mw.html.create("div") :css({			["display"] = "flex",			["max-width"] = data.maxWidth		}) local rowSpacing = data.rowSpacing or defaultSpacing html:css({		["margin-top"] = "-" .. rowSpacing,		["flex-direction"] = "column",		["align-items"] = "stretch",		["justify-content"] = "center",	}) local pages = {} local items = data.items if type(items[1]) == "string" or type(items[1]) == "table" and items[1].page then items = { items } end for _, rowItems in ipairs(items) do		local rowData = { fileType = data.fileType, scale = data.scale, size = data.fileSize, spacing = data.spacing, items = rowItems, }		local row, rowPages = h.printRow(game, rowData) pages = utilsTable.concat(pages, rowPages) html:tag("div") :wikitext(row) :css({				["margin-top"] = rowSpacing,			}) :done end return tostring(html), pages end

function h.printRow(game, data, options) local vertical = options and options.vertical local styles = options and options.styles local pages = {} local row = mw.html.create("div") :css({			["display"] = "flex",			["flex-wrap"] = "wrap",			["flex-direction"] = (vertical and "column") or "row",			["justify-content"] = "center",			["align-items"] = "flex-end",			["margin-top"] = "-" .. defaultSpacing,			["margin-left"] = "-" .. (data.spacing or defaultSpacing),		}) :css(styles or {}) for _, item in ipairs(data.items) do		local img = h.rowItem(game, data, item) table.insert(pages, item) row :tag("div") :wikitext(img) :css({				["margin-top"] = defaultSpacing,				["margin-left"] = (data.spacing or defaultSpacing),			}) :done end return tostring(row), pages end

function h.rowItem(game, rowData, item) local page, file if type(item) == "table" and item.page then page = item.page file = item.image else page = item end local term = Term.fetchTerm(page, game) term = term and string.gsub(term, "''", "") -- workaround needed as long as markup is stored in terms local file = file or table.concat({game, term or page, rowData.fileType}, " ") .. ".png" local img, exists = File.image(file, {		link = page,		noBacklink = true,		caption = term or page,		scale = rowData.scale,		size = rowData.size,	}) if not exists then utilsError.warn("No such file: "..utilsMarkup.link("File:"..file)) img = "" if mw.title.getCurrentTitle.nsText == "Template" then img = "" -- Add in Template namespace so this category doesn't get added for every transclusion end end return img end function h.createMapNav(game, mapData) local states, pages, defaultState = h.printMapStates(mapData) local leftColumn if mapData.leftColumn then local column, columnPages = h.printRow(game, mapData.leftColumn, {			vertical = true,			styles = {				["margin-right"] = defaultSpacing,			},		}) defaultState = utilsTable.keyOf(columnPages, currentPage) or defaultState pages = utilsTable.concat(pages, columnPages) leftColumn = column end return h.printStatefulMap(states, defaultState, mapData.maxWidth, leftColumn), pages end

function h.printMapStates(mapData) local states = {} local baseMapData = mapData local baseMap, pages = h.printImageMap(mapData) local defaultState = utilsTable.keyOf(pages, currentPage) and 1 or nil table.insert(states, baseMap) for i, upgradeData in ipairs(mapData.upgrades or {}) do		local upgradeMapData = h.resolveUpgradeMap(baseMapData, upgradeData) local upgradeMap, upgradePages = h.printImageMap(upgradeMapData) defaultState = defaultState or utilsTable.keyOf(upgradePages, currentPage) and i + 1 pages = utilsTable.concat(pages, upgradePages) table.insert(states, upgradeMap) baseMapData = upgradeMapData end return states, pages, defaultState end

function h.resolveUpgradeMap(mapData, upgradeData) local areas = {} for i, area in ipairs(mapData.areas) do		local before = mapData.areas[i] local change = upgradeData.changes[before.page] if not change then table.insert(areas, before) elseif change == "" then -- no-op elseif type(change) == "string" then table.insert(areas, {				area = before.area,				page = change,				display = before.display			}) else table.insert(areas, {				area = change.area or before.area,				page = change.page or before.page,				display = change.display			}) end end areas = utilsTable.concat(upgradeData.areas, areas) local upgradeMapData = { image = upgradeData.image, maxWidth = mapData.maxWidth, areas = areas, }	return upgradeMapData end

function h.printStatefulMap(states, defaultState, maxWidth, leftColumn) defaultState = defaultState or 1 local statesNode = mw.html.create("div") :addClass(classes.states) :css({			["display"] = "flex",			["align-items"] = "center",			["justify-content"] = "center"		}) for i, state in ipairs(states) do 		local default = i == defaultState statesNode :tag("div") :addClass(default and classes.defaultState or classes.state) :css("flex", "1") :wikitext(state) :done end local controlCss = { ["height"] = "12%", ["min-height"] = "32px", ["margin-left"] = "15%", }	local controlsNode = mw.html.create("div") :addClass(classes.controls) :tag("div") :addClass(classes.stateControlForward) :addClass(defaultState == #states and classes.stateControlDisabled or nil) :css(controlCss) :wikitext("") :done :tag("div") :addClass(classes.stateControlBack) :addClass(defaultState == 1 and classes.stateControlDisabled or nil) :css(controlCss) :wikitext("") :done local stateContainer = mw.html.create("div") :addClass(classes.stateContainer) -- TODO: :node(mw.clone(controlsNode):css("visibility", "hidden")) --Hack to "balance" the left side and keep the content centered :node(leftColumn) :node(statesNode) :node(controlsNode) :node(leftColumn and mw.html.create("div"):css("visibility", "hidden"):node(leftColumn)) :css({			["display"] = "flex",			["justify-content"] = "center",			["margin-bottom"] = defaultSpacing,		}) return tostring(stateContainer) end

function h.printImageMap(data) local lines = { data.image .. "|" .. data.maxWidth } local pages = {} for _, area in ipairs(data.areas) do		local page = area.page local term = Term.fetchTerm(page, area.game) local href = tostring(mw.uri.fullUrl(page)) local link = ("[%s %s]"):format(href, area.display or term or page) table.insert(pages, page) table.insert(lines, area.area .. link) end table.insert(lines, " desc none") lines = table.concat(lines, "\n") local imagemap = mw.getCurrentFrame:extensionTag("imagemap", lines) local responsiveImageMap = mw.html.create("div") :wikitext(imagemap) return tostring(responsiveImageMap), pages end

function p.Data(frame) local games = {} for _, game in ipairs(Franchise.enumGames) do		if Franchise.canonicity(game) == "canon" and not Franchise.isRemake(game) then table.insert(games, p.printTemplateLink(game)) local remakes = {} for _, remake in ipairs(Franchise.remakes(game)) do				table.insert(remakes, p.printTemplateLink(remake)) end table.insert(games, remakes) end end local message = "Use the Preview page with this form to view imagemaps individually.\n\n To view all imagemaps at once, see Module:Items/All. Note that loading this many imagemaps will take upwards of 10 seconds, which is why  no longer exists." return message .. utilsMarkup.bulletList(games) end function p.printTemplateLink(game) local indicator = "" -- if not data[game] then -- 	indicator = "*" -- elseif pcall(function p.printNav(game) end) then -- 	indicator = "" -- else -- 	indicator = "" -- end return "Template:" .. game .. " Items" .. indicator end

function p.TemplateDocumentation(frame) local game = frame.args[1] local usageFrame = utilsMarkup.inline(string.format("", game), { nowiki = true, code = true }) local usageFrameless = utilsMarkup.inline(string.format("", game), { nowiki = true, code = true }) local doc = frame:expandTemplate({		title = "Module:Items/Template/Documentation",		args = {Franchise.link(game), usageFrame, usageFrameless, Franchise.shortName(game), p.printLinksTable(game)}	}) doc = doc .. ""	return doc end function p.printLinksTable(game) local list, pages = p.getLinkList(game, data[game]) local categoryItems = utilsPage.dpl({		category = "Items in " .. Franchise.shortName(game),		namespace = "", -- main		ordermethod = "title",		redirects = "include",	}) local omittedItems = utilsTable.difference(categoryItems, pages) local extraItems = utilsTable.difference(pages, categoryItems) omittedItems = utilsTable.map(omittedItems, utilsMarkup.link) extraItems = utilsTable.map(extraItems, utilsMarkup.link) return utilsLayout.table({		headers = {"Links", "Omitted Items", "Extra Items"},		rows = {			{				styles = {					["vertical-align"] = "top"				},				cells = {utilsMarkup.definitionList(list), utilsMarkup.bulletList(omittedItems), utilsMarkup.bulletList(extraItems)}			}		}	}) end function p.getLinkList(game, tabs) local definitionList = {} local pages = {} for _, tab in ipairs(tabs) do		local definition = {} definition[1] = utilsTable.size(tabs) > 1 and tab.tab or "" -- #tabs doesn't work because of how mw.loadData works if tab.subtabs then local subtabList, subtabPages = p.getLinkList(game, tab.subtabs) definition[2] = subtabList pages = utilsTable.concat(pages, subtabPages) elseif tab.map then local mapItems = utilsTable.map(tab.map.areas, "page") for _, upgrade in ipairs(tab.map.upgrades or {}) do				mapItems = utilsTable.concat(mapItems, upgrade.areas or {}) for _, v in pairs(upgrade.changes) do					if type(v) == "string" and v ~= "" or type(v) == "table" and v.page then table.insert(mapItems, v)					end end end mapItems = utilsTable.concat(mapItems, tab.map.leftColumn and tab.map.leftColumn.items) local links = utilsTable.map(mapItems, function(item)				return Term.printTerm({ page = type(item) == "table" and item.page or item, game = game, link = "link", display = type(item) == "table" and item.display or nil, })			end) local subtabPages = utilsTable.map(mapItems, function(item)				return type(item) == "table" and item.page or item			end) table.sort(links) definition[2] = utilsMarkup.bulletList(links) pages = utilsTable.concat(pages, subtabPages) elseif tab.rows then local items = utilsTable.flatten(tab.rows.items) items = utilsTable.map(items, function(item)				if type(item) == "table" and item.page then					return item.page				elseif type(item) == "string" then					return item				end			end) table.sort(items) local links = utilsTable.map(items, function(item)				return Term.printTerm({ page = item, game = game, link = "link", })			end) definition[2] = utilsMarkup.bulletList(links) pages = utilsTable.concat(pages, items) end table.insert(definitionList, definition) end return definitionList, pages end

p.Schemas = { Data = { type = "map", keys = { type = "string", enum = Franchise.enum },		values = { allOf = { {					type = "array", items = { _ref = "#/definitions/tab" } },				{					type = "record", properties = { {							name = "tabColumns", type = "number", desc = "Corresponds to the paramter on Module:UtilsLayout. Determines the number of tabs per row." },					},				},			},		},		desc = "A map of game codes to one or more image-based navs for that game. If there is more than one nav in a game, these are displayed as tabs. An additional  key can be used to indicate how many tabs to render per row.", definitions = { area = { type = "record", required = true, properties = { {						name = "area", type = "string", -- TODO: regex required = true, desc = "The shape and coordinates of the area. For example: ", },					{						name = "page", type = "string", required = true, desc = "The name of the page to link to." },					{						name = "display", default = "Term.fetchTerm(game, page) or page", type = "string", desc = "The alt text for the link. Defaults to the game term stored on the linked page. If there is no term, defaults to the page name.", },				},			},			upgrade = { type = "record", properties = { {						name = "image", type = "string", required = true, desc = "File name for the upgrade state image map.", },					{						name = "changes", type = "map", keys = { type = "string" }, values = { oneOf = { { 									type = "string", desc = 'The new page that an area must link to. If set to empty string, the area will be unset.', },								{ 									type = "record", desc = "Override other properties of an area, namely its coordinates.", properties = { { name = "area", type = "string" }, -- TODO: regex { name = "page", type = "string" }, { name = "display", type = "string" }, },								},							}						},						desc = 							Used to model state transitions for items that occupy the same inventory slot.							By default, the "upgrade state" image map has all the same areas as the previous state. 							Use this property to modify which page an area links to, without having to repeat coordinates for it.							This property can also be used to 'unset' areas which are no longer applicable. },					{						name = "areas", type = "array", items = { _ref = "#/definitions/area", _hideSubkeys = true }, desc = "A list of new areas that are applicable to the upgrade state and its successors.", },				},			},			imagemap = { type = "record", properties = { {						name = "image", type = "string", required = true, desc = "Base file name for the image map." },					{						name = "maxWidth", type = "number", desc = "Maximum width in pixels for the responsive image.", },					{						name = "areas", type = "array", required = true, items = { _ref = "#/definitions/area", },						desc = "The clickable areas of the image map.", },					{						name = "upgrades", type = "array", items = { _ref = "#/definitions/upgrade" }, desc = 							Represents upgraded inventory states, where each state is rendered as its own imagemap that is clicked to with "upgrade" and "downgrade" arrows.							This is used in cases such as .. "Template:OoA Items" .. , when different inventory items occupy the same inventory slot at different points in the game.							In these cases, the goal should be to represent all the items in as few "upgrade" states as possible, even if this means some items appearing together that never would in-game. 							It's not feasible to represent every possible inventory state as this A) detracts from the actual purpose of navigation, and B) generates too many image maps affecting page size and loading time. },					{						name = "leftColumn", _ref = "#/definitions/rows", _hideSubkeys = true, desc = "Designed specifically for Template:LADX Items.", },				},			},			rows = { type = "record", properties = { {						name = "fileType", type = "string", required = true, },					{						name = "scale", -- TODO: remove? type = "number", default = 1, },					{						name = "fileSize", -- TODO: remove? type = "string", },					{						name = "spacing", type = "string", default = defaultSpacing, desc = "Horizontal space between each image in the row.", },					{						name = "rowSpacing", type = "string", default = defaultSpacing, desc = "Space between rows, if there are more than one." },					{						name = "maxWidth", type = "number", desc = "Maximum width in pixels for the rows.", },					{						name = "items", required = true, desc = 							An array of strings representing an image row. Or, an array of rows if there are multiple rows.							The thumbnail for each file in the row is generated as follows (not taking into account the size, which is computed automatically): .. "\n" .. utilsMarkup.code("") .. ,						oneOf = { {								type = "array", items = { oneOf = { {											type = "string", },										{											type = "record", properties = { {													name = "image", required = true, type = "string", desc = "Custom filename", },												{													name = "page", required = true, type = "string", desc = "Wiki article to link to.", },											},										},									},								},							},							{								type = "array", items = { type = "array", items = { oneOf = { {												type = "string" },											{												type = "record", properties = { {														name = "image", required = true, type = "string", desc = "Custom filename", },													{														name = "page", required = true, type = "string", desc = "Wiki article to link to.", },												},											},										},									},								},							},						},					},				}			},			tab = { allOf = { {						type = "record", properties = { {								name = "tab", type = "string", required = true, desc = "Tab label. Displayed only when game has more than one tab.", },							{								name = "tabCaption", type = "string", desc = "Tooltip to be displayed for the tab.", },						},					},					{						oneOf = { ["Image Maps"] = { type = "record", desc = "Generates image maps of items that mimic in-game inventory.", properties = { {										name = "map", required = true, _ref = "#/definitions/imagemap", }								},							},							["Subtabs"] = { type = "record", desc = 'A nav can have one or more "subnavs". These usually map to in-game submenus. For example, see Template:TWW Items, "Bag Items" tab.', properties = { {										name = "subtabs", required = true, type = "array", items = { _ref = "#/definitions/tab" }, }								},							},							["Rows"] = { type = "record", desc = "Icons or sprites of items displayed in one or more rows. Used for items that do not appear in inventory screens.", properties = { {										name = "rows", required = true, _ref = "#/definitions/rows", }								},							},						},					},				},			}		}	} }

return p