Module:ItemsNav

local p = {} local h = {}

local Tab2 = require("Module:Tab2") local Term = require("Module:Term") local utilsArg = require("Module:UtilsArg") local utilsError = require("Module:UtilsError") local utilsGame = require("Module:UtilsGame") local utilsMarkup = require("Module:UtilsMarkup") 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 local game

function p.Main(frame) local args = frame:getParent.args local output = p.main({		game = args[1],		articleType = args[2],		frame = args.frame,		header = args.header,		footer = args.footer,		align = args.align,	}) return output end

function p.main(args) local err = utilsArg.validate(args, {		articleType = {			required = true,			values = utilsTable.keys(data.navData)		},		game = {			required = true,			values = function(args)				return utilsTable.keys(data.navData[args.articleType])			end		}	}) if err then utilsError.logWarnings(err) return utilsError.printErrorCategories(utilsError.INVALID_TEMPLATE_ARGUMENTS) end return p.createNav(args) end

function p.createNav(args) game = args.game local navData = data.navData[args.articleType] and data.navData[args.articleType][game] if not navData then return "" end local defaults = data.defaults[args.articleType] local nav = h.createTabbedNav(navData) if not args.frame then return nav end local gameSubtitle = utilsGame.AbbToGame(game, true) local header = args.header or utilsString.interpolate(defaults.header, { game = gameSubtitle }) local footer = args.footer or defaults.footer return utilsMarkup.wikitable({		align = args.align or "center",		styles = {			["margin-top"] = "1em",			["margin-bottom"] = "1em",		},		rows = {			{				header = true,				cells = { header }			},			{				cells = { nav },			},			{				footer = true,				cells = { footer },			},		},	}) end

function h.createTabbedNav(config) if not config[1].tab then return tostring(h.createNav(config[1])) end local tabs = {} local defaultTab for i, v in pairs(config) do		local tab, pages = h.createNav(v) table.insert(tabs, {			tabName = v.tab,			tabCaption = v.caption,			tabContent = tostring(tab),		}) if not defaultTab then --defaultTab = utilsTable.keyOf(pages, currentPage) and i		end end defaultTab = defaultTab or 1 return tostring(Tab2.Main(tabs, defaultTab, "top", nil, nil, nil, nil, "center")), pages end

function h.createNav(config) if config.subtabs then return h.createTabbedNav(config.subtabs) end if config.map then return h.createMapNav(config.map) end if config.row then return h.createRowNav(config.row) end if config.multirow then return h.createMultirowNav(config.multirow) end end

function h.createMultirowNav(data) local html = mw.html.create("div") :css("display", "flex") local rowSpacing = data.rowSpacing or defaultSpacing local marginDirection = data.singleLine and "margin-left" or "margin-top" local flexDirection = data.singleLine and "row" or "column" html:css({		[marginDirection] = "-" .. rowSpacing,		["flex-direction"] = flexDirection,		["align-items"] = data.singleLine and "flex-end" or "stretch",		["justify-content"] = "center",	}) local pages = {} for _, rowItems in ipairs(data.items) do		local rowData = { fileType = data.fileType, scale = data.scale, spacing = data.itemSpacing, items = rowItems, }		local row, rowPages = h.createRowNav(rowData) pages = utilsTable.mergeArrays(pages, rowPages) html:tag("div") :wikitext(row) :css({				[marginDirection] = rowSpacing,			}) :done end return tostring(html), pages end

function h.createRowNav(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		table.insert(pages, item) row :tag("div") :wikitext(h.rowItem(data, item)) :css({				["margin-top"] = defaultSpacing,				["margin-left"] = (data.spacing or defaultSpacing),			}) :done end return tostring(row), pages end

function h.rowItem(rowData, item) if utilsString.startsWith("[[File:", item) then		return item	end	local page, term = h.getLinkParts(item)	local fileName = table.concat({game, term, rowData.fileType}, " ")	local size = rowData.fileSize	if not size then		local fileWidth = rowData.scale and mw.title.makeTitle("File", fileName).file.width		local thumbnailWidth = fileWidth and fileWidth * rowData.scale		size = thumbnailWidth and thumbnailWidth .. "px"	end	return utilsMarkup.file(fileName, {		size = size,		link = page,		caption = term,	}) end function h.createMapNav(config)	local baseImageMap, pages = h.createImageMap(config)	if not config.upgrades then		return baseImageMap, pages	end

local leftColumn if config.leftColumn then leftColumn, columnPages = h.createRowNav(config.leftColumn, { 			vertical = true,			styles = {				["margin-right"] = defaultSpacing,			},		}) pages = utilsTable.mergeArrays(pages, columnPages) end local upgradeMaps, upgradePages = h.resolveUpgradeMaps(config) local states = utilsTable.mergeArrays({ baseImageMap }, upgradeMaps) pages = utilsTable.mergeArrays({pages}, upgradePages) local defaultState = h.getUpgradeIndexOfCurrentPage(pages) pages = utilsTable.flatten(pages) return h.createStatefulMenuMap(states, defaultState, config.maxWidth, leftColumn), pages end

function h.getUpgradeIndexOfCurrentPage(pages) for i, mapPages in ipairs(pages) do		if utilsTable.keyOf(mapPages, currentPage) then return i		end end return 1 end

function h.resolveUpgradeMaps(data) local upgradeMaps = {} local pages = {} local initialAreas = utilsTable.shallowClone(data.areas) for i, upgradeData in pairs(data.upgrades) do		local updateArea = h.updateArea(upgradeData.changes) local changedAreas = utilsTable.map(updateArea, initialAreas) changedAreas = utilsTable.removeFalseEntries(changedAreas) local addedAreas = upgradeData.areas or {} local upgradeAreas = utilsTable.mergeArrays(addedAreas, changedAreas) local upgradeMap = h.createImageMap({			image = upgradeData.image,			areas = upgradeAreas,			maxWidth = data.maxWidth,		}) local upgradePages = utilsTable.map("page", upgradeAreas) table.insert(upgradeMaps, upgradeMap) table.insert(pages, upgradePages) end return upgradeMaps, pages end

function h.updateArea(changes) return function(area) local change = changes[area.page] if not change then return area end if change == "" then return nil end if type(change) == "string" then change = { page = change } end local newArea = { area = change.area or area.area, page = change.page or area.page, display = change.display, }		return newArea end end

function h.createStatefulMenuMap(states, defaultState, maxWidth, leftColumn) local statesNode = mw.html.create("div") :addClass(classes.states) :css({				["display"] = "flex",				["align-items"] = "center",				["justify-content"] = "center",				["flex"] = "1",				["max-width"] = maxWidth and maxWidth .. "px",		}) 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) :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",		}) return stateContainer end

function h.createImageMap(data) local lines = { data.image } local pages = {} for _, area in ipairs(data.areas) do		local link = ("[%s %s]"):format(h.getLinkParts(area.page, area.display)) table.insert(pages, area.page) table.insert(lines, area.area .. link) end table.insert(lines, " desc none") local imagemap = mw.getCurrentFrame:extensionTag("imagemap", table.concat(lines, "\n")) local responsiveImageMap = mw.html.create("div") :addClass("resizable-image-map") :css({			["max-width"] = data.maxWidth and data.maxWidth .. "px",			["margin"] = "0 auto",		}) :wikitext(imagemap) return tostring(responsiveImageMap), pages end

-- For rendering a page link as an external link, so as to not spam Special:WhatLinksHere function h.getLinkParts(page, display) local url = mw.title.new(page):fullUrl if display then return url, display end local row = Term.fetchRow({ term = page, game = game }) local alt = row and row.term or page alt = mw.text.trim(alt, "'") -- workaround for the fact that the term string can include formatting characters (e.g. Cuccodex) return url, alt end

p.Schemas = { Data = { type = "map", keys = { type = "string", enum = utilsGame.getEnum },		values = { type = "array", items = { _ref = "#/definitions/tab" } },		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.", 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", -- TODO: regex 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, 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 = "singleLine", type = "boolean", desc = 'If true and there are multiple rows, attempt to render rows on a single line. For example, see Template:SS Items, "Dungeon Items" tab.', },					{						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 = { type = "string" }, },							{								type = "array", items = { type = "array", items = { type = "string" }, },							},						},					},				}			},			tab = { allOf = { {						type = "record", properties = { name = "tab", type = "string", required = true, desc = "Tab label. Displayed only when game has more than one tab.", }					},					{						oneOf = { ["Image Maps"] = { type = "record", desc = "Generates image maps of items that mimic in-game inventory.", properties = { {										name = "map", _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", 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.", -- TODO properties = { {										name = "rows", _ref = "#/definitions/rows", }								}							},						},					},				},			}		}	} }

return p