Zelda Wiki

Want to contribute to this wiki?
Sign up for an account, and get started!

Come join the Zelda Wiki community Discord server!

READ MORE

Zelda Wiki
mNo edit summary
mNo edit summary
Line 204: Line 204:
 
img = ""
 
img = ""
 
if mw.title.getCurrentTitle().nsText == "Template" then
 
if mw.title.getCurrentTitle().nsText == "Template" then
img = "[[Category:Pages with Invalid Arguments]]" -- Add in Template namespace so this category doesn't get added for every transclusion
+
img = "[[Category:Navigation Templates Needing Attention]]" -- Add in Template namespace so this category doesn't get added for every transclusion
 
end
 
end
 
end
 
end

Revision as of 23:57, 21 October 2020

This module is used to generate image maps such as Template:SS Items. The image maps are generated from the data at Module:Items/Data.

Though there is a learning curve to inputting the data, these image maps have the following advantages:

  • ability to represent different upgrade states using arrows
  • auto-selection of tab and upgrade state based on the current page
  • Uses external links so as not to spam Special:WhatLinksHere

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 MAX_FRAME_WIDTH = 750

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 gameData = data[game]
	local nav = h.createNav(game, gameData)
	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,
	})
	local hasMap = #utilsTable.filter(gameData, "map") > 0
	return utilsLayout.table({
		styles = {
			["margin"] = "1em auto",
			["width"] = hasMap and "100%" or nil,
			["max-width"] = (gameData.maxWidth or MAX_FRAME_WIDTH) .. "px"
		},
		rows = {
			{
				header = true,
				cells = {header}
			},
			{
				cells = {nav},
			},
			{
				footer = true,
				cells = {footer},
			},
		},
	})
end

function h.createNav(game, data, isSubtabs)
	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",
		tabOptions = {
			collapse = true,
			columns = data.tabColumns or (not isSubtabs and #tabs) or nil,
		},
		labelOptions = {
			alignVertical = "bottom",
		},
		contentOptions = {
			contentWidth = true,
		}
	}), pages
end

function h.createNavComponent(game, data)
	if data.subtabs then
		return h.createNav(game, data.subtabs, true)
	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"
		})
	local rowSpacing = data.rowSpacing or defaultSpacing
	html:css({
		["margin-top"] = "-" .. rowSpacing,
		["flex-direction"] = "column",
		["align-items"] = "stretch",
		["justify-content"] = "center",
		["max-width"] = data.maxWidth and data.maxWidth .. "px",
		["margin-left"] = "auto",
		["margin-right"] = "auto",
	})
	
	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,
		scaleUsingCargo = true,
		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 = "[[Category:Navigation Templates Needing Attention]]" -- 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",
			["max-width"] = maxWidth and maxWidth .. "px", 
			["flex-basis"] = "80%"
		})
	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("[[File:Up Arrow.png|link=]]")
			:done()
		:tag("div")
			:addClass(classes.stateControlBack)
			:addClass(defaultState == 1 and classes.stateControlDisabled or nil)
			:css(controlCss)
			:wikitext("[[File:Down Arrow.png|link=]]")
			: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 }
	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")
		:addClass("responsive-imagemap")
		: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 20 seconds, which is why <code>Template:Items</code> 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 = "[[File:TFH Red Link desperate.png|48px]]"
	-- end
	return "[[Template:" .. game .. " Items]]" .. indicator
end

function p.TemplateDocumentation(frame)
	local game = frame.args[1]
	local usageFrame = utilsMarkup.inline(string.format("{{%s Items}}", game), { nowiki = true, code = true })
	local usageFrameless = utilsMarkup.inline(string.format("{{%s Items|-}}", 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 .. "[[Category:Item Navigation Templates]]"
	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 = "maxFrameWidth",
							type = "number",
							default = MAX_FRAME_WIDTH,
							desc = "If the nav is 'framed' in a wikitable, this determines the max width of the wikitable."
						},
						{
							name = "tabColumns",
							type = "number",
							desc = "Corresponds to the paramter on [[Module:UtilsLayout#tabs]]. Determines the number of tabs per row."
						},
					},
				},
			},
		},
		desc = "A map of [[Data:Franchise|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 [[Module:UtilsLayout#tabs|tabs]]. An additional <code>colums</code> 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: <code>rect 314 84 372 123</code>",
					},
					{
						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 (<code>""</code>), 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 [[Template:Responsive Imagemap|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. If unset, the rows will use up all available page width.",
					},
					{
						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("[[File:<game> <item> <fileType>.png|link=<fetchTerm(item) or item>|<fetchTerm(item) or item>]]") .. [[
						]],
						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 [[Module:UtilsLayout#flex|rows]]. Used for items that do not appear in inventory screens.",
								properties = {
									{
										name = "rows",
										required = true,
										_ref = "#/definitions/rows",
									}
								},
							},
						},
					},
				},
			}
		}
	}
}

return p