Module:MasonryLayout: Difference between revisions
// via Wikitext Extension for VSCode |
// via Wikitext Extension for VSCode |
||
| (18 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
--[[ | --[[ | ||
* Name: MasonryLayout | |||
* Author: Mark W. Datysgeld | |||
* Description: Intelligent masonry layout system for content distribution with card organization across columns for optimal visual balance | |||
* Notes: Content-aware size estimation; intelligent card distribution algorithm; responsive column management; aggressive caching for performance; Blueprint integration; error handling integration; integrates with ErrorHandling for protected operations; uses TemplateHelpers caching mechanisms and utility functions; integrates with TemplateStructure for block-based rendering | |||
]] | ]] | ||
| Line 25: | Line 11: | ||
local EMPTY_STRING = '' | local EMPTY_STRING = '' | ||
local DEFAULT_COLUMNS = 3 | local DEFAULT_COLUMNS = 3 | ||
local MOBILE_BREAKPOINT = | local MOBILE_BREAKPOINT = 656 -- 41rem to match CSS breakpoint | ||
-- Size estimation constants (based on typical MediaWiki table rendering) | -- Size estimation constants (based on typical MediaWiki table rendering) | ||
| Line 41: | Line 26: | ||
-- Column distribution weights for different screen sizes | -- Column distribution weights for different screen sizes | ||
local RESPONSIVE_CONFIG = { | local RESPONSIVE_CONFIG = { | ||
desktop = { columns = 3 | desktop = { columns = 3, minWidth = MOBILE_BREAKPOINT + 1 }, | ||
mobile = { columns = 1, maxWidth = MOBILE_BREAKPOINT } | mobile = { columns = 1, maxWidth = MOBILE_BREAKPOINT } | ||
} | } | ||
| Line 107: | Line 91: | ||
-- Estimate the rendered height of a card based on its content | -- Estimate the rendered height of a card based on its content | ||
-- @param cardData table Card information: {title, rowCount, contentType, hasLongNames, isEmpty} | -- @param cardData table Card information: {title, rowCount, contentType, hasLongNames, isEmpty, rawDataCount} | ||
-- @param options table Options: {mobileMode, customFactors} | -- @param options table Options: {mobileMode, customFactors} | ||
-- @return number Estimated height in pixels | -- @return number Estimated height in pixels | ||
| Line 117: | Line 101: | ||
return 0 | return 0 | ||
end | end | ||
-- Use raw data count if available for most accurate estimation | |||
local effectiveRowCount = cardData.rawDataCount or cardData.rowCount or 0 | |||
-- Generate cache key | -- Generate cache key | ||
| Line 122: | Line 109: | ||
'masonryCardSize', | 'masonryCardSize', | ||
cardData.title or '', | cardData.title or '', | ||
effectiveRowCount, | |||
cardData.contentType or 'default', | cardData.contentType or 'default', | ||
cardData.hasLongNames and 'longNames' or 'shortNames', | cardData.hasLongNames and 'longNames' or 'shortNames', | ||
| Line 129: | Line 116: | ||
return TemplateHelpers.withCache(cacheKey, function() | return TemplateHelpers.withCache(cacheKey, function() | ||
-- Card padding constant (15px top + 15px bottom from CSS) | |||
local CARD_PADDING = 30 | |||
-- Special handling for intro cards (text content, not tables) | -- Special handling for intro cards (text content, not tables) | ||
if cardData.contentType == 'intro' then | if cardData.contentType == 'intro' then | ||
-- Intro is typically 2-3 lines of welcome text + card padding | |||
return 120 + CARD_PADDING -- Fixed height for consistency | |||
return | |||
end | end | ||
-- Special handling for infoBox | -- Special handling for infoBox | ||
if cardData.contentType == 'infoBox' then | if cardData.contentType == 'infoBox' then | ||
-- InfoBox has fixed structure: header + 4-5 rows + card padding | |||
return 200 + CARD_PADDING -- Fixed height based on actual structure | |||
return | |||
end | end | ||
-- | -- Precise calculation for data tables | ||
local | -- Based on actual MediaWiki table rendering measurements: | ||
local TABLE_HEADER = 45 -- Table header with title | |||
local TABLE_PADDING = 20 -- Top/bottom padding | |||
local ROW_HEIGHT = 28 -- Actual height per row in pixels | |||
local BORDER_SPACING = 2 -- Border between rows | |||
-- | -- Calculate exact height based on row count | ||
local | local contentHeight = TABLE_HEADER + TABLE_PADDING + CARD_PADDING | ||
-- Add | if effectiveRowCount > 0 then | ||
-- Each row takes ROW_HEIGHT plus border spacing | |||
contentHeight = contentHeight + (effectiveRowCount * (ROW_HEIGHT + BORDER_SPACING)) | |||
-- Add extra height for long content names (wrapping) | |||
if cardData.hasLongNames then | |||
-- Long names typically wrap to 2 lines | |||
local wrapBonus = math.floor(effectiveRowCount * 0.3) * ROW_HEIGHT | |||
contentHeight = contentHeight + wrapBonus | |||
end | |||
end | end | ||
-- Add | -- Add small buffer for margin/padding variations | ||
contentHeight = contentHeight + 15 | |||
-- Mobile adjustments | -- Mobile adjustments (slightly more spacing) | ||
if options.mobileMode then | if options.mobileMode then | ||
contentHeight = contentHeight * 1.1 | |||
end | end | ||
-- | -- Apply reasonable bounds | ||
contentHeight = math.max(80, contentHeight) -- Minimum height | |||
contentHeight = math.min(2000, contentHeight) -- Maximum height (very large tables) | |||
return math.floor(contentHeight) | |||
end) | end) | ||
end | end | ||
| Line 255: | Line 244: | ||
-- @return table Distribution result: {columns, heights, balance} | -- @return table Distribution result: {columns, heights, balance} | ||
function p.distributeCardsIntelligent(cards, columnCount) | function p.distributeCardsIntelligent(cards, columnCount) | ||
-- Pre-allocate column arrays | -- Pre-allocate column arrays | ||
local columns = {} | local columns = {} | ||
local columnHeights = {} | local columnHeights = {} | ||
| Line 266: | Line 255: | ||
-- Separate special positioning cards from regular cards | -- Separate special positioning cards from regular cards | ||
local regularCards = {} | local regularCards = {} | ||
local | local infoBoxCard = nil | ||
local introCard = nil | |||
for _, card in ipairs(cards) do | for _, card in ipairs(cards) do | ||
if card.id == 'infoBox' then | if card.id == 'infoBox' then | ||
infoBoxCard = card | |||
elseif card.id == 'intro' then | elseif card.id == 'intro' then | ||
introCard = card | |||
else | else | ||
table.insert(regularCards, card) | table.insert(regularCards, card) | ||
| Line 280: | Line 268: | ||
end | end | ||
-- Place | -- Place intro and infoBox first for alignment | ||
if introCard then | |||
table.insert(columns[1], introCard) | |||
columnHeights[1] = introCard.estimatedSize or 0 | |||
end | end | ||
if infoBoxCard then | |||
table.insert(columns[ | table.insert(columns[columnCount], infoBoxCard) | ||
columnHeights[columnCount] = infoBoxCard.estimatedSize or 0 | |||
end | end | ||
-- | -- Calculate total size and identify extreme cases | ||
local totalRegularSize = 0 | |||
local cardSizes = {} | |||
for _, card in ipairs(regularCards) do | for _, card in ipairs(regularCards) do | ||
local size = card.estimatedSize or 0 | |||
totalRegularSize = totalRegularSize + size | |||
table.insert(cardSizes, {card = card, size = size}) | |||
end | end | ||
-- | -- Sort by size descending for optimal packing | ||
local | table.sort(cardSizes, function(a, b) return a.size > b.size end) | ||
-- Identify extremely large cards (using actual pixel calculations) | |||
local extremeCards = {} | |||
local normalCards = {} | |||
local extremeThreshold = totalRegularSize * 0.4 -- 40% of total content | |||
for _, item in ipairs(cardSizes) do | |||
if item.size > extremeThreshold and #extremeCards < columnCount then | |||
table.insert(extremeCards, item.card) | |||
else | |||
table.insert(normalCards, item.card) | |||
end | end | ||
end | end | ||
-- | -- Phase 1: Distribute extreme cards to minimize imbalance | ||
if | if #extremeCards > 0 then | ||
-- | -- Find columns with least content (excluding special cards initially) | ||
local availableColumns = {} | |||
for i = 1, columnCount do | |||
for i = 1, | -- Skip columns with special cards for initial extreme card placement | ||
columns | if not ((i == 1 and introCard) or (i == columnCount and infoBoxCard)) then | ||
table.insert(availableColumns, {column = i, height = columnHeights[i]}) | |||
end | |||
end | end | ||
-- | |||
for i = | -- If all columns have special cards, include them | ||
columns[i] = | if #availableColumns == 0 then | ||
for i = 1, columnCount do | |||
table.insert(availableColumns, {column = i, height = columnHeights[i]}) | |||
end | |||
end | |||
-- Sort by height ascending | |||
table.sort(availableColumns, function(a, b) return a.height < b.height end) | |||
-- Place extreme cards in shortest columns | |||
for i, extremeCard in ipairs(extremeCards) do | |||
if availableColumns[i] then | |||
local targetColumn = availableColumns[i].column | |||
table.insert(columns[targetColumn], extremeCard) | |||
columnHeights[targetColumn] = columnHeights[targetColumn] + (extremeCard.estimatedSize or 0) | |||
end | |||
end | end | ||
end | end | ||
-- | -- Phase 2: Distribute remaining cards using modified best-fit algorithm | ||
for _, card in ipairs( | for _, card in ipairs(normalCards) do | ||
-- Calculate which column would result in best overall balance | |||
local bestColumn = 1 | local bestColumn = 1 | ||
local | local bestScore = math.huge | ||
-- | for i = 1, columnCount do | ||
-- Simulate adding this card to column i | |||
local simulatedHeight = columnHeights[i] + (card.estimatedSize or 0) | |||
-- Calculate variance if we add to this column | |||
local variance = 0 | |||
local totalHeight = 0 | |||
for j = 1, columnCount do | |||
local h = (j == i) and simulatedHeight or columnHeights[j] | |||
totalHeight = totalHeight + h | |||
end | |||
local avgHeight = totalHeight / columnCount | |||
for j = 1, columnCount do | |||
local h = (j == i) and simulatedHeight or columnHeights[j] | |||
local diff = h - avgHeight | |||
variance = variance + (diff * diff) | |||
end | |||
-- Prefer columns that minimize variance | |||
if variance < bestScore then | |||
bestScore = variance | |||
bestColumn = i | bestColumn = i | ||
end | end | ||
end | end | ||
-- Place card | -- Place card in best column | ||
table.insert(columns[bestColumn], card) | table.insert(columns[bestColumn], card) | ||
columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0) | columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0) | ||
end | end | ||
-- | -- Phase 3: Post-optimization - try to swap cards between columns to improve balance | ||
-- This | -- This is a simple optimization pass that can significantly improve results | ||
for i = | local improved = true | ||
local iterations = 0 | |||
local maxIterations = 10 | |||
while improved and iterations < maxIterations do | |||
improved = false | |||
iterations = iterations + 1 | |||
-- Find most and least loaded columns | |||
local maxCol, minCol = 1, 1 | |||
local maxHeight, minHeight = columnHeights[1], columnHeights[1] | |||
for i = 2, columnCount do | |||
if columnHeights[i] > maxHeight then | |||
maxHeight = columnHeights[i] | |||
maxCol = i | |||
end | |||
if columnHeights[i] < minHeight then | |||
minHeight = columnHeights[i] | |||
minCol = i | |||
end | |||
end | |||
-- Try to move a card from max to min column | |||
if maxCol ~= minCol and maxHeight - minHeight > 100 then -- Significant imbalance | |||
-- Find a card in maxCol that would improve balance | |||
for i = #columns[maxCol], 1, -1 do | |||
local card = columns[maxCol][i] | |||
-- Skip special cards | |||
if card.id ~= 'intro' and card.id ~= 'infoBox' then | |||
local cardSize = card.estimatedSize or 0 | |||
-- Check if moving this card would improve balance | |||
local newMaxHeight = maxHeight - cardSize | |||
local newMinHeight = minHeight + cardSize | |||
if math.abs(newMaxHeight - newMinHeight) < math.abs(maxHeight - minHeight) then | |||
-- Move the card | |||
table.remove(columns[maxCol], i) | |||
table.insert(columns[minCol], card) | |||
columnHeights[maxCol] = newMaxHeight | |||
columnHeights[minCol] = newMinHeight | |||
improved = true | |||
break | |||
end | |||
end | |||
end | |||
end | |||
end | end | ||
| Line 363: | Line 430: | ||
heights = columnHeights, | heights = columnHeights, | ||
balance = calculateBalance(columnHeights), | balance = calculateBalance(columnHeights), | ||
totalSize = totalRegularSize, | |||
extremeCards = #extremeCards, | |||
iterations = iterations | |||
} | } | ||
end | end | ||
| Line 463: | Line 531: | ||
if cardContent and cardContent ~= EMPTY_STRING then | if cardContent and cardContent ~= EMPTY_STRING then | ||
-- Get raw data count if available | |||
local rawDataCount = template._rawDataCounts and template._rawDataCounts[cardDef.blockId] or nil | |||
-- Analyze the content for size estimation | -- Analyze the content for size estimation | ||
local cardData = p.analyzeCardContent(cardContent, cardDef) | local cardData = p.analyzeCardContent(cardContent, cardDef, rawDataCount) | ||
cardData.id = cardDef.blockId | cardData.id = cardDef.blockId | ||
cardData.title = cardDef.title or cardDef.blockId | cardData.title = cardDef.title or cardDef.blockId | ||
| Line 487: | Line 558: | ||
-- @param cardContent string Rendered HTML content | -- @param cardContent string Rendered HTML content | ||
-- @param cardDef table Card definition | -- @param cardDef table Card definition | ||
-- @param rawDataCount number Optional: actual count of data entries from SMW query | |||
-- @return table Card data for size estimation | -- @return table Card data for size estimation | ||
function p.analyzeCardContent(cardContent, cardDef) | function p.analyzeCardContent(cardContent, cardDef, rawDataCount) | ||
if not cardContent or cardContent == EMPTY_STRING then | if not cardContent or cardContent == EMPTY_STRING then | ||
return { | return { | ||
isEmpty = true, | isEmpty = true, | ||
rowCount = 0, | rowCount = 0, | ||
contentType = cardDef.blockId | contentType = cardDef.blockId, | ||
rawDataCount = 0 | |||
} | } | ||
end | end | ||
-- Count table rows in the content (look for <tr> tags or table row patterns) | -- Use raw data count if provided (most accurate) | ||
local _, trCount = cardContent:gsub('<tr[^>]*>', '') | local rowCount = rawDataCount or 0 | ||
-- Fallback: Count table rows in the content (look for <tr> tags or table row patterns) | |||
if rowCount == 0 then | |||
local _, trCount = cardContent:gsub('<tr[^>]*>', '') | |||
local _, wikiRowCount = cardContent:gsub('|-', '') | |||
rowCount = math.max(trCount, wikiRowCount) | |||
end | |||
-- | -- Final fallback: estimate based on content length | ||
if rowCount == 0 and #cardContent > 100 then | if rowCount == 0 and #cardContent > 100 then | ||
-- Rough estimation: every ~200 characters might be a row | -- Rough estimation: every ~200 characters might be a row | ||
| Line 516: | Line 594: | ||
contentType = cardDef.blockId, | contentType = cardDef.blockId, | ||
hasLongNames = hasLongNames, | hasLongNames = hasLongNames, | ||
contentLength = #cardContent | contentLength = #cardContent, | ||
rawDataCount = rawDataCount or rowCount -- Store the actual count for debugging | |||
} | } | ||
end | end | ||
| Line 545: | Line 624: | ||
-- Render cards in this column | -- Render cards in this column | ||
for j, card in ipairs(columnCards) do | for j, card in ipairs(columnCards) do | ||
-- ALL cards should be wrapped in the card class for consistent styling | |||
cardHtml[j] = string.format( | cardHtml[j] = string.format( | ||
'<div class="%s" data-card-id="%s">%s</div>', | '<div class="%s" data-card-id="%s">%s</div>', | ||
| Line 573: | Line 653: | ||
if screenWidth <= MOBILE_BREAKPOINT then | if screenWidth <= MOBILE_BREAKPOINT then | ||
return 1 | return 1 | ||
else | else | ||
return 3 | return 3 | ||
| Line 638: | Line 716: | ||
local blockRenderers = config.blockRenderers or {} | local blockRenderers = config.blockRenderers or {} | ||
local columnCount = options.columns or DEFAULT_COLUMNS | -- Determine render mode (mobile vs desktop) | ||
-- Default to desktop mode, but can be overridden by options | |||
local isMobileMode = options.mobileMode or false | |||
local columnCount = isMobileMode and 1 or (options.columns or DEFAULT_COLUMNS) | |||
-- Build cards with render-time content generation | -- Build cards with render-time content generation | ||
| Line 676: | Line 757: | ||
end | end | ||
-- | -- Branch based on render mode | ||
local distribution = p.distributeCards(cards, columnCount) | if isMobileMode or columnCount == 1 then | ||
-- MOBILE MODE: Single column with explicit ordering | |||
local orderedCards = {} | |||
local introCard = nil | |||
local infoBoxCard = nil | |||
local otherCards = {} | |||
-- Separate special cards from regular cards | |||
for _, card in ipairs(cards) do | |||
if card.id == 'intro' then | |||
introCard = card | |||
elseif card.id == 'infoBox' then | |||
infoBoxCard = card | |||
else | |||
table.insert(otherCards, card) | |||
end | |||
end | |||
-- Build final ordered list: intro → infoBox → others | |||
if introCard then | |||
table.insert(orderedCards, introCard) | |||
end | |||
if infoBoxCard then | |||
table.insert(orderedCards, infoBoxCard) | |||
end | |||
for _, card in ipairs(otherCards) do | |||
table.insert(orderedCards, card) | |||
end | |||
-- Render as single column layout | |||
local containerClass = options.containerClass or 'country-hub-masonry-container' | |||
local cardClass = options.cardClass or 'country-hub-masonry-card' | |||
local masonryHtml = string.format('<div class="%s country-hub-mobile-mode">', containerClass) | |||
-- Add debug information | |||
masonryHtml = masonryHtml .. string.format( | |||
'<!-- Masonry Debug: Total cards: %d, Mobile single column layout -->', | |||
#orderedCards | |||
) | |||
-- Render each card in order | |||
for _, card in ipairs(orderedCards) do | |||
masonryHtml = masonryHtml .. string.format( | |||
'\n<div class="%s" data-card-id="%s">%s</div>', | |||
cardClass, | |||
card.id or 'unknown', | |||
card.content or EMPTY_STRING | |||
) | |||
end | |||
masonryHtml = masonryHtml .. '\n</div>' | |||
return masonryHtml | |||
else | |||
-- DESKTOP MODE: Multi-column with intelligent distribution | |||
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) | |||
-- Add debug information as HTML comments | |||
if distribution then | |||
masonryHtml = masonryHtml .. string.format( | |||
'<!-- Masonry Debug: Total cards: %d, Columns: %d, Heights: [%s], Balance: %.2f, Extreme cards: %d -->', | |||
#cards, | |||
#(distribution.columns or {}), | |||
table.concat(distribution.heights or {}, ', '), | |||
distribution.balance or 0, | |||
distribution.extremeCards or 0 | |||
) | |||
-- Add per-card debug info | |||
for i, column in ipairs(distribution.columns or {}) do | |||
masonryHtml = masonryHtml .. string.format('\n<!-- Column %d: ', i) | |||
for _, card in ipairs(column) do | |||
masonryHtml = masonryHtml .. string.format('%s(%dpx) ', card.id or 'unknown', card.estimatedSize or 0) | |||
end | |||
masonryHtml = masonryHtml .. '-->' | |||
end | |||
end | |||
masonryHtml = masonryHtml .. '\n' .. p.renderDistributedLayout(distribution, options) | |||
masonryHtml = masonryHtml .. '</div>' | |||
return masonryHtml | |||
end | |||
end | end | ||