Module:MasonryLayout
Appearance
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()
return p.distributeCardsIntelligent(cards, columnCount)
end)
end
-- Intelligent card distribution with balance optimization
-- @param cards table Array of card objects with size estimates
-- @param columnCount number Target number of columns
-- @return table Distribution result: {columns, heights, balance}
function p.distributeCardsIntelligent(cards, columnCount)
-- Pre-allocate column arrays for performance
local columns = {}
local columnHeights = {}
for i = 1, columnCount do
columns[i] = {}
columnHeights[i] = 0
end
-- Separate special positioning cards from regular cards
local regularCards = {}
local specialCards = {}
for _, card in ipairs(cards) do
if card.id == 'infoBox' then
-- InfoBox always goes to rightmost column
table.insert(specialCards, {card = card, targetColumn = columnCount})
elseif card.id == 'intro' then
-- Intro always goes to leftmost column
table.insert(specialCards, {card = card, targetColumn = 1})
else
table.insert(regularCards, card)
end
end
-- Place special cards first
for _, special in ipairs(specialCards) do
table.insert(columns[special.targetColumn], special.card)
columnHeights[special.targetColumn] = columnHeights[special.targetColumn] + (special.card.estimatedSize or 0)
end
-- Sort regular cards by size (largest first) for better distribution
table.sort(regularCards, function(a, b)
return (a.estimatedSize or 0) > (b.estimatedSize or 0)
end)
-- Calculate total content size and average per column
local totalSize = 0
for _, card in ipairs(regularCards) do
totalSize = totalSize + (card.estimatedSize or 0)
end
local averageColumnHeight = totalSize / columnCount
-- Smart distribution: check if we should use fewer columns for better balance
local optimalColumnCount = columnCount
-- If we have very few cards or very uneven sizes, consider using fewer columns
if #regularCards <= 2 then
optimalColumnCount = math.min(#regularCards, columnCount)
else
-- Check if largest card is more than 60% of total content
local largestCard = regularCards[1]
if largestCard and largestCard.estimatedSize > (totalSize * 0.6) then
-- Very unbalanced content - use fewer columns
optimalColumnCount = math.max(2, math.min(columnCount, #regularCards))
end
end
-- Redistribute columns if we're using fewer
if optimalColumnCount < columnCount then
-- Reset columns for optimal count
columns = {}
columnHeights = {}
for i = 1, optimalColumnCount do
columns[i] = {}
columnHeights[i] = 0
end
-- Add empty columns at the end
for i = optimalColumnCount + 1, columnCount do
columns[i] = {}
columnHeights[i] = 0
end
end
-- Greedy placement with balance checking
for _, card in ipairs(regularCards) do
local bestColumn = 1
local bestHeight = columnHeights[1]
-- Find the best column (shortest among active columns)
for i = 2, optimalColumnCount do
if columnHeights[i] < bestHeight then
bestColumn = i
bestHeight = columnHeights[i]
end
end
-- Place card and update height
table.insert(columns[bestColumn], card)
columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0)
end
return {
columns = columns,
heights = columnHeights,
balance = calculateBalance(columnHeights),
optimalColumnCount = optimalColumnCount,
totalSize = totalSize
}
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 (look for <tr> tags or table row patterns)
local _, trCount = cardContent:gsub('<tr[^>]*>', '')
local _, wikiRowCount = cardContent:gsub('|-', '')
local rowCount = math.max(trCount, wikiRowCount)
-- If no explicit rows found, estimate based on content length
if rowCount == 0 and #cardContent > 100 then
-- Rough estimation: every ~200 characters might be a row
rowCount = math.ceil(#cardContent / 200)
end
-- Check for long content (rough heuristic)
local hasLongNames = #cardContent > 1000 or cardContent:find('[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+')
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
-- ========== Intelligent Layout Rendering ==========
-- Main render function for intelligent masonry layout
-- This is the core function that coordinates content rendering, analysis, and distribution
-- @param template table Template object with features and configuration
-- @param args table Template arguments
-- @param config table Configuration with cardDefinitions, options, and blockRenderers
-- @return string Complete masonry layout HTML
function p.renderIntelligentLayout(template, args, config)
-- Create render-time error context (Blueprint pattern)
local errorContext = ErrorHandling.createContext("MasonryLayout")
return ErrorHandling.protect(
errorContext,
"renderIntelligentLayout",
function()
return p.renderIntelligentLayoutInternal(template, args, config, errorContext)
end,
EMPTY_STRING,
template, args, config
)
end
-- Internal implementation of intelligent layout rendering
-- @param template table Template object
-- @param args table Template arguments
-- @param config table Configuration object
-- @param errorContext table Error context for protected operations
-- @return string Complete masonry layout HTML
function p.renderIntelligentLayoutInternal(template, args, config, errorContext)
local cardDefinitions = config.cardDefinitions or {}
local options = config.options or {}
local blockRenderers = config.blockRenderers or {}
local columnCount = options.columns or DEFAULT_COLUMNS
-- Build cards with render-time content generation
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 = blockRenderers[cardDef.blockId]
if renderer and renderer.render then
-- Render the card content at render-time (not configuration-time)
local cardContent = ErrorHandling.protect(
errorContext,
"renderCard_" .. cardDef.blockId,
function()
return renderer.render(template, args)
end,
EMPTY_STRING,
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 complete masonry layout
local containerClass = options.containerClass or 'country-hub-masonry-container'
local masonryHtml = string.format('<div class="%s">', containerClass)
masonryHtml = masonryHtml .. p.renderDistributedLayout(distribution, options)
masonryHtml = masonryHtml .. '</div>'
return masonryHtml
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