Module:MasonryLayout
Documentation for this module may be created at Module:MasonryLayout/doc
--[[
* MasonryLayout.lua
* Intelligent masonry layout system for content distribution
*
* This module provides smart card distribution across columns for optimal visual balance.
* Integrates with the Blueprint architecture and follows ICANNWiki performance patterns.
*
* Key Features:
* - Content-aware size estimation
* - Intelligent card distribution algorithm
* - Responsive column management
* - Aggressive caching for performance
* - Blueprint integration
* - Error handling integration
*
* Integration with other modules:
* - ErrorHandling: All operations are protected with centralized error handling
* - TemplateHelpers: Uses caching mechanisms and utility functions
* - TemplateStructure: Integrates with block-based rendering
]]
local p = {}
-- ========== Constants as Upvalues ==========
local EMPTY_STRING = ''
local DEFAULT_COLUMNS = 3
local MOBILE_BREAKPOINT = 480
local TABLET_BREAKPOINT = 768
-- Size estimation constants (based on typical MediaWiki table rendering)
local SIZE_ESTIMATES = {
BASE_CARD_HEIGHT = 120, -- Base table overhead (header + borders + padding)
HEADER_HEIGHT = 40, -- Table header height
ROW_HEIGHT = 25, -- Average height per table row
TITLE_CHAR_FACTOR = 1.2, -- Additional height for long titles
CONTENT_OVERFLOW_FACTOR = 15, -- Extra height for content overflow
MIN_CARD_HEIGHT = 80, -- Minimum card height
MAX_CARD_HEIGHT = 800 -- Maximum card height (safety limit)
}
-- Column distribution weights for different screen sizes
local RESPONSIVE_CONFIG = {
desktop = { columns = 3, minWidth = TABLET_BREAKPOINT + 1 },
tablet = { columns = 2, minWidth = MOBILE_BREAKPOINT + 1, maxWidth = TABLET_BREAKPOINT },
mobile = { columns = 1, maxWidth = MOBILE_BREAKPOINT }
}
-- ========== Required modules ==========
local ErrorHandling = require('Module:ErrorHandling')
local TemplateHelpers = require('Module:TemplateHelpers')
local NormalizationText = require('Module:NormalizationText')
-- ========== Module-level Caches ==========
local sizeCache = {}
local distributionCache = {}
-- ========== Utility Functions ==========
-- Generate a signature for a set of cards to use in cache keys
-- @param cards table Array of card objects
-- @return string A signature representing the card set
local function generateCardSignature(cards)
if not cards or #cards == 0 then
return 'empty'
end
local parts = {}
for i, card in ipairs(cards) do
parts[i] = string.format('%s:%d:%s',
card.id or 'unknown',
card.estimatedSize or 0,
card.contentType or 'default'
)
end
return table.concat(parts, '|')
end
-- Calculate balance score for column heights (lower is better)
-- @param columnHeights table Array of column heights
-- @return number Balance score (0 = perfect balance)
local function calculateBalance(columnHeights)
if not columnHeights or #columnHeights < 2 then
return 0
end
local total = 0
local count = #columnHeights
-- Calculate average height
for _, height in ipairs(columnHeights) do
total = total + height
end
local average = total / count
-- Calculate variance from average
local variance = 0
for _, height in ipairs(columnHeights) do
local diff = height - average
variance = variance + (diff * diff)
end
return variance / count
end
-- ========== Core Size Estimation ==========
-- Estimate the rendered height of a card based on its content
-- @param cardData table Card information: {title, rowCount, contentType, hasLongNames, isEmpty}
-- @param options table Options: {mobileMode, customFactors}
-- @return number Estimated height in pixels
function p.estimateCardSize(cardData, options)
options = options or {}
-- Handle empty cards
if cardData.isEmpty then
return 0
end
-- Generate cache key
local cacheKey = TemplateHelpers.generateCacheKey(
'masonryCardSize',
cardData.title or '',
cardData.rowCount or 0,
cardData.contentType or 'default',
cardData.hasLongNames and 'longNames' or 'shortNames',
options.mobileMode and 'mobile' or 'desktop'
)
return TemplateHelpers.withCache(cacheKey, function()
-- Base height calculation
local baseHeight = SIZE_ESTIMATES.BASE_CARD_HEIGHT
-- Add height for table rows
local rowCount = cardData.rowCount or 0
local rowHeight = rowCount * SIZE_ESTIMATES.ROW_HEIGHT
-- Add bonus for long titles
local titleBonus = 0
if cardData.title and #cardData.title > 20 then
titleBonus = SIZE_ESTIMATES.TITLE_CHAR_FACTOR * (#cardData.title - 20)
end
-- Add bonus for long content names
local contentBonus = 0
if cardData.hasLongNames then
contentBonus = rowCount * 5 -- Extra height per row for long names
end
-- Mobile adjustments
local mobileBonus = 0
if options.mobileMode then
mobileBonus = rowCount * 3 -- Slightly more height on mobile
end
-- Calculate total height
local totalHeight = baseHeight + rowHeight + titleBonus + contentBonus + mobileBonus
-- Apply bounds
totalHeight = math.max(SIZE_ESTIMATES.MIN_CARD_HEIGHT, totalHeight)
totalHeight = math.min(SIZE_ESTIMATES.MAX_CARD_HEIGHT, totalHeight)
return totalHeight
end)
end
-- Analyze SMW query results to estimate card content
-- @param queryResults table Results from SMW query
-- @param cardType string Type of card ('organizations', 'people', etc.)
-- @return table Card data for size estimation
function p.analyzeQueryResults(queryResults, cardType)
if not queryResults or #queryResults == 0 then
return {
isEmpty = true,
rowCount = 0,
contentType = cardType
}
end
local rowCount = #queryResults
local hasLongNames = false
local totalNameLength = 0
-- Analyze content for long names
for _, result in ipairs(queryResults) do
local name = result.result or result[1] or ''
totalNameLength = totalNameLength + #name
if #name > 30 then
hasLongNames = true
end
end
local averageNameLength = totalNameLength / rowCount
return {
isEmpty = false,
rowCount = rowCount,
contentType = cardType,
hasLongNames = hasLongNames or averageNameLength > 25,
averageNameLength = averageNameLength
}
end
-- ========== Smart Distribution Algorithm ==========
-- Distribute cards across columns for optimal balance
-- @param cards table Array of card objects with size estimates
-- @param columnCount number Target number of columns (default: 3)
-- @return table Distribution result: {columns, heights, balance}
function p.distributeCards(cards, columnCount)
columnCount = columnCount or DEFAULT_COLUMNS
-- Handle edge cases
if not cards or #cards == 0 then
local emptyColumns = {}
local emptyHeights = {}
for i = 1, columnCount do
emptyColumns[i] = {}
emptyHeights[i] = 0
end
return {
columns = emptyColumns,
heights = emptyHeights,
balance = 0
}
end
-- Generate cache key
local cardSignature = generateCardSignature(cards)
local cacheKey = TemplateHelpers.generateCacheKey('masonryDistribution', cardSignature, columnCount)
return TemplateHelpers.withCache(cacheKey, function()
-- Pre-allocate column arrays for performance
local columns = {}
local columnHeights = {}
for i = 1, columnCount do
columns[i] = {}
columnHeights[i] = 0
end
-- Create a copy of cards for sorting (don't modify original)
local sortedCards = {}
for i, card in ipairs(cards) do
sortedCards[i] = card
end
-- Sort cards by size (largest first) for better distribution
table.sort(sortedCards, function(a, b)
return (a.estimatedSize or 0) > (b.estimatedSize or 0)
end)
-- Greedy placement: always choose shortest column
for _, card in ipairs(sortedCards) do
local shortestColumn = 1
local shortestHeight = columnHeights[1]
-- Find shortest column (optimized loop)
for i = 2, columnCount do
if columnHeights[i] < shortestHeight then
shortestColumn = i
shortestHeight = columnHeights[i]
end
end
-- Place card and update height
table.insert(columns[shortestColumn], card)
columnHeights[shortestColumn] = columnHeights[shortestColumn] + (card.estimatedSize or 0)
end
return {
columns = columns,
heights = columnHeights,
balance = calculateBalance(columnHeights)
}
end)
end
-- ========== Blueprint Integration ==========
-- Create Blueprint-compatible block functions for masonry layout
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return table Blueprint block configuration
function p.createMasonryBlocks(cardDefinitions, options)
options = options or {}
return {
masonryWrapperOpen = {
feature = 'fullPage',
render = function(template, args)
return p.renderMasonryOpen(options)
end
},
masonryContent = {
feature = 'fullPage',
render = function(template, args)
return p.renderMasonryContent(template, args, cardDefinitions, options)
end
},
masonryWrapperClose = {
feature = 'fullPage',
render = function(template, args)
return p.renderMasonryClose()
end
}
}
end
-- Render the opening masonry container
-- @param options table Layout options
-- @return string HTML for masonry container opening
function p.renderMasonryOpen(options)
options = options or {}
local cssClass = options.containerClass or 'country-hub-masonry-container'
return string.format('<div class="%s">', cssClass)
end
-- Render the closing masonry container
-- @return string HTML for masonry container closing
function p.renderMasonryClose()
return '</div>'
end
-- Render the main masonry content with intelligent distribution
-- @param template table Template object
-- @param args table Template arguments
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return string Complete masonry layout HTML
function p.renderMasonryContent(template, args, cardDefinitions, options)
if not template._errorContext then
template._errorContext = ErrorHandling.createContext("MasonryLayout")
end
return ErrorHandling.protect(
template._errorContext,
"renderMasonryContent",
function()
return p.renderMasonryContentInternal(template, args, cardDefinitions, options)
end,
EMPTY_STRING,
template, args, cardDefinitions, options
)
end
-- Internal masonry content rendering (protected by error handling)
-- @param template table Template object
-- @param args table Template arguments
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return string Complete masonry layout HTML
function p.renderMasonryContentInternal(template, args, cardDefinitions, options)
options = options or {}
local columnCount = options.columns or DEFAULT_COLUMNS
-- Build cards with content analysis
local cards = {}
local cardIndex = 1
for _, cardDef in ipairs(cardDefinitions) do
-- Check if this card's feature is enabled
if template.features[cardDef.feature] then
-- Get the block renderer for this card
local renderer = template._blocks and template._blocks[cardDef.blockId]
if renderer and renderer.render then
-- Render the card content
local cardContent = renderer.render(template, args)
if cardContent and cardContent ~= EMPTY_STRING then
-- Analyze the content for size estimation
local cardData = p.analyzeCardContent(cardContent, cardDef)
cardData.id = cardDef.blockId
cardData.title = cardDef.title or cardDef.blockId
cardData.content = cardContent
cardData.estimatedSize = p.estimateCardSize(cardData, options)
cards[cardIndex] = cardData
cardIndex = cardIndex + 1
end
end
end
end
-- Distribute cards across columns
local distribution = p.distributeCards(cards, columnCount)
-- Render the distributed layout
return p.renderDistributedLayout(distribution, options)
end
-- Analyze rendered card content to extract size information
-- @param cardContent string Rendered HTML content
-- @param cardDef table Card definition
-- @return table Card data for size estimation
function p.analyzeCardContent(cardContent, cardDef)
if not cardContent or cardContent == EMPTY_STRING then
return {
isEmpty = true,
rowCount = 0,
contentType = cardDef.blockId
}
end
-- Count table rows in the content (rough estimation)
local _, rowCount = cardContent:gsub('|-', '')
-- Check for long content (rough heuristic)
local hasLongNames = #cardContent > 1000 or cardContent:find('%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w')
return {
isEmpty = false,
rowCount = math.max(1, rowCount), -- At least 1 row if content exists
contentType = cardDef.blockId,
hasLongNames = hasLongNames,
contentLength = #cardContent
}
end
-- Render the final distributed layout
-- @param distribution table Distribution result from distributeCards
-- @param options table Layout options
-- @return string Complete HTML for the distributed layout
function p.renderDistributedLayout(distribution, options)
options = options or {}
local columnClass = options.columnClass or 'country-hub-masonry-column'
local cardClass = options.cardClass or 'country-hub-masonry-card'
local columns = distribution.columns
local columnCount = #columns
if columnCount == 0 then
return EMPTY_STRING
end
-- Build HTML for each column
local columnHtml = {}
for i = 1, columnCount do
local columnCards = columns[i]
local cardHtml = {}
-- Render cards in this column
for j, card in ipairs(columnCards) do
cardHtml[j] = string.format(
'<div class="%s" data-card-id="%s">%s</div>',
cardClass,
card.id or 'unknown',
card.content or EMPTY_STRING
)
end
-- Wrap column
columnHtml[i] = string.format(
'<div class="%s" data-column="%d">%s</div>',
columnClass,
i,
table.concat(cardHtml, '\n')
)
end
return table.concat(columnHtml, '\n')
end
-- ========== Responsive Utilities ==========
-- Get responsive column count based on screen size
-- @param screenWidth number Screen width in pixels
-- @return number Appropriate column count
function p.getResponsiveColumns(screenWidth)
if screenWidth <= MOBILE_BREAKPOINT then
return 1
elseif screenWidth <= TABLET_BREAKPOINT then
return 2
else
return 3
end
end
-- Generate responsive CSS classes
-- @param options table Layout options
-- @return string CSS classes for responsive behavior
function p.generateResponsiveClasses(options)
options = options or {}
local baseClass = options.baseClass or 'country-hub-masonry'
local classes = {baseClass}
if options.mobileColumns then
table.insert(classes, baseClass .. '-mobile-' .. options.mobileColumns)
end
if options.tabletColumns then
table.insert(classes, baseClass .. '-tablet-' .. options.tabletColumns)
end
if options.desktopColumns then
table.insert(classes, baseClass .. '-desktop-' .. options.desktopColumns)
end
return table.concat(classes, ' ')
end
-- ========== Debug and Utilities ==========
-- Get distribution statistics for debugging
-- @param distribution table Distribution result
-- @return table Statistics about the distribution
function p.getDistributionStats(distribution)
if not distribution or not distribution.columns then
return {
columnCount = 0,
totalCards = 0,
balance = 0,
heights = {}
}
end
local totalCards = 0
for _, column in ipairs(distribution.columns) do
totalCards = totalCards + #column
end
return {
columnCount = #distribution.columns,
totalCards = totalCards,
balance = distribution.balance or 0,
heights = distribution.heights or {},
averageHeight = distribution.heights and (#distribution.heights > 0) and
(table.concat(distribution.heights, '+') / #distribution.heights) or 0
}
end
-- Clear caches (for debugging/testing)
function p.clearCaches()
sizeCache = {}
distributionCache = {}
-- Also clear TemplateHelpers cache if needed
end
return p