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:
--[[
--[[
* MasonryLayout.lua
* Name: MasonryLayout
* Intelligent masonry layout system for content distribution
* Author: Mark W. Datysgeld
*
* Description: Intelligent masonry layout system for content distribution with card organization across columns for optimal visual balance
* This module provides smart card distribution 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
* 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
]]
]]


Line 25: Line 11:
local EMPTY_STRING = ''
local EMPTY_STRING = ''
local DEFAULT_COLUMNS = 3
local DEFAULT_COLUMNS = 3
local MOBILE_BREAKPOINT = 480
local MOBILE_BREAKPOINT = 656  -- 41rem to match CSS breakpoint
local TABLET_BREAKPOINT = 768


-- 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, minWidth = TABLET_BREAKPOINT + 1 },
     desktop = { columns = 3, minWidth = MOBILE_BREAKPOINT + 1 },
    tablet = { columns = 2, minWidth = MOBILE_BREAKPOINT + 1, maxWidth = TABLET_BREAKPOINT },
     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 '',
         cardData.rowCount or 0,
         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
             local textLength = cardData.contentLength or 0
             -- Intro is typically 2-3 lines of welcome text + card padding
            local estimatedHeight = math.max(80, math.min(200, textLength / 4))
             return 120 + CARD_PADDING -- Fixed height for consistency
             return estimatedHeight
         end
         end
          
          
         -- Special handling for infoBox to ensure conservative estimation
         -- Special handling for infoBox
         if cardData.contentType == 'infoBox' then
         if cardData.contentType == 'infoBox' then
             local baseHeight = SIZE_ESTIMATES.BASE_CARD_HEIGHT * 0.8 -- More conservative base
             -- InfoBox has fixed structure: header + 4-5 rows + card padding
            local rowCount = cardData.rowCount or 5 -- Default to 5 rows for infoBox
             return 200 + CARD_PADDING -- Fixed height based on actual structure
            local rowHeight = rowCount * SIZE_ESTIMATES.ROW_HEIGHT
             return baseHeight + rowHeight
         end
         end
          
          
         -- Base height calculation for regular cards
         -- Precise calculation for data tables
         local baseHeight = SIZE_ESTIMATES.BASE_CARD_HEIGHT
        -- 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
          
          
         -- Add height for table rows
         -- Calculate exact height based on row count
         local rowCount = cardData.rowCount or 0
         local contentHeight = TABLE_HEADER + TABLE_PADDING + CARD_PADDING
        local rowHeight = rowCount * SIZE_ESTIMATES.ROW_HEIGHT
          
          
         -- Add bonus for long titles
         if effectiveRowCount > 0 then
        local titleBonus = 0
            -- Each row takes ROW_HEIGHT plus border spacing
        if cardData.title and #cardData.title > 20 then
            contentHeight = contentHeight + (effectiveRowCount * (ROW_HEIGHT + BORDER_SPACING))
            titleBonus = SIZE_ESTIMATES.TITLE_CHAR_FACTOR * (#cardData.title - 20)
           
            -- 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 bonus for long content names
         -- Add small buffer for margin/padding variations
         local contentBonus = 0
         contentHeight = contentHeight + 15
        if cardData.hasLongNames then
            contentBonus = rowCount * 5 -- Extra height per row for long names
        end
          
          
         -- Mobile adjustments
         -- Mobile adjustments (slightly more spacing)
        local mobileBonus = 0
         if options.mobileMode then
         if options.mobileMode then
             mobileBonus = rowCount * 3 -- Slightly more height on mobile
             contentHeight = contentHeight * 1.1
         end
         end
          
          
         -- Calculate total height
         -- Apply reasonable bounds
         local totalHeight = baseHeight + rowHeight + titleBonus + contentBonus + mobileBonus
        contentHeight = math.max(80, contentHeight)  -- Minimum height
         contentHeight = math.min(2000, contentHeight) -- Maximum height (very large tables)
          
          
         -- Apply bounds
         return math.floor(contentHeight)
        totalHeight = math.max(SIZE_ESTIMATES.MIN_CARD_HEIGHT, totalHeight)
        totalHeight = math.min(SIZE_ESTIMATES.MAX_CARD_HEIGHT, totalHeight)
       
        return totalHeight
     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 for performance
     -- 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 specialCards = {}
     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
             -- InfoBox always goes to rightmost column
             infoBoxCard = card
            table.insert(specialCards, {card = card, targetColumn = columnCount})
         elseif card.id == 'intro' then
         elseif card.id == 'intro' then
             -- Intro always goes to leftmost column
             introCard = card
            table.insert(specialCards, {card = card, targetColumn = 1})
         else
         else
             table.insert(regularCards, card)
             table.insert(regularCards, card)
Line 280: Line 268:
     end
     end
      
      
     -- Place special cards first but use deferred height calculation for alignment
     -- Place intro and infoBox first for alignment
     local specialCardHeights = {}
     if introCard then
    for i = 1, columnCount do
        table.insert(columns[1], introCard)
         specialCardHeights[i] = 0
         columnHeights[1] = introCard.estimatedSize or 0
     end
     end
      
      
     for _, special in ipairs(specialCards) do
     if infoBoxCard then
         table.insert(columns[special.targetColumn], special.card)
         table.insert(columns[columnCount], infoBoxCard)
         specialCardHeights[special.targetColumn] = special.card.estimatedSize or 0
         columnHeights[columnCount] = infoBoxCard.estimatedSize or 0
        -- Don't add to columnHeights yet - this ensures regular cards distribute from equal starting points
     end
     end
      
      
     -- Sort regular cards by size (largest first) for better distribution
     -- Calculate total size and identify extreme cases
     table.sort(regularCards, function(a, b)
     local totalRegularSize = 0
        return (a.estimatedSize or 0) > (b.estimatedSize or 0)
     local cardSizes = {}
     end)
      
      
    -- Calculate total content size and average per column
    local totalSize = 0
     for _, card in ipairs(regularCards) do
     for _, card in ipairs(regularCards) do
         totalSize = totalSize + (card.estimatedSize or 0)
         local size = card.estimatedSize or 0
        totalRegularSize = totalRegularSize + size
        table.insert(cardSizes, {card = card, size = size})
     end
     end
    local averageColumnHeight = totalSize / columnCount
      
      
     -- Smart distribution: check if we should use fewer columns for better balance
     -- Sort by size descending for optimal packing
     local optimalColumnCount = columnCount
    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
      
      
     -- If we have very few cards or very uneven sizes, consider using fewer columns
     for _, item in ipairs(cardSizes) do
    if #regularCards <= 2 then
        if item.size > extremeThreshold and #extremeCards < columnCount then
        optimalColumnCount = math.min(#regularCards, columnCount)
            table.insert(extremeCards, item.card)
    else
        else
        -- Check if largest card is more than 60% of total content
             table.insert(normalCards, item.card)
        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
     end
     end
      
      
     -- Redistribute columns if we're using fewer
     -- Phase 1: Distribute extreme cards to minimize imbalance
     if optimalColumnCount < columnCount then
     if #extremeCards > 0 then
         -- Reset columns for optimal count
         -- Find columns with least content (excluding special cards initially)
        columns = {}
         local availableColumns = {}
         columnHeights = {}
         for i = 1, columnCount do
         for i = 1, optimalColumnCount do
             -- Skip columns with special cards for initial extreme card placement
             columns[i] = {}
            if not ((i == 1 and introCard) or (i == columnCount and infoBoxCard)) then
            columnHeights[i] = 0
                table.insert(availableColumns, {column = i, height = columnHeights[i]})
            end
         end
         end
         -- Add empty columns at the end
       
         for i = optimalColumnCount + 1, columnCount do
         -- If all columns have special cards, include them
             columns[i] = {}
         if #availableColumns == 0 then
            columnHeights[i] = 0
            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
      
      
     -- Greedy placement with balance checking
     -- Phase 2: Distribute remaining cards using modified best-fit algorithm
     for _, card in ipairs(regularCards) do
     for _, card in ipairs(normalCards) do
        -- Calculate which column would result in best overall balance
         local bestColumn = 1
         local bestColumn = 1
         local bestHeight = columnHeights[1]
         local bestScore = math.huge
          
          
         -- Find the best column (shortest among active columns)
         for i = 1, columnCount do
        for i = 2, optimalColumnCount do
            -- Simulate adding this card to column i
            if columnHeights[i] < bestHeight then
            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
                bestHeight = columnHeights[i]
             end
             end
         end
         end
          
          
         -- Place card and update height
         -- 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
      
      
     -- Now add special card heights for final balance calculation
     -- Phase 3: Post-optimization - try to swap cards between columns to improve balance
     -- This ensures special cards are aligned at the top but still count toward total column height
     -- This is a simple optimization pass that can significantly improve results
     for i = 1, columnCount do
    local improved = true
        columnHeights[i] = columnHeights[i] + specialCardHeights[i]
    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),
         optimalColumnCount = optimalColumnCount,
         totalSize = totalRegularSize,
         totalSize = totalSize
         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
    local _, wikiRowCount = cardContent:gsub('|-', '')
   
    local rowCount = math.max(trCount, wikiRowCount)
    -- 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
      
      
     -- If no explicit rows found, estimate based on content length
     -- 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
    elseif screenWidth <= TABLET_BREAKPOINT then
        return 2
     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
      
      
     -- Distribute cards across columns
     -- Branch based on render mode
     local distribution = p.distributeCards(cards, columnCount)
    if isMobileMode or columnCount == 1 then
   
        -- MOBILE MODE: Single column with explicit ordering
    -- Render the complete masonry layout
        local orderedCards = {}
    local containerClass = options.containerClass or 'country-hub-masonry-container'
        local introCard = nil
    local masonryHtml = string.format('<div class="%s">', containerClass)
        local infoBoxCard = nil
    masonryHtml = masonryHtml .. p.renderDistributedLayout(distribution, options)
        local otherCards = {}
    masonryHtml = masonryHtml .. '</div>'
       
   
        -- Separate special cards from regular cards
    return masonryHtml
        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