Module:MasonryLayout: Difference between revisions

// via Wikitext Extension for VSCode
// via Wikitext Extension for VSCode
 
(13 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 132: 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
             -- Intro is typically 2-3 lines of welcome text + card padding
             return 120 -- Fixed height for consistency
             return 120 + CARD_PADDING -- Fixed height for consistency
         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
             -- InfoBox has fixed structure: header + 4-5 rows + card padding
             return 200 -- Fixed height based on actual structure
             return 200 + CARD_PADDING -- Fixed height based on actual structure
         end
         end
          
          
Line 152: Line 139:
          
          
         -- Calculate exact height based on row count
         -- Calculate exact height based on row count
         local contentHeight = TABLE_HEADER + TABLE_PADDING
         local contentHeight = TABLE_HEADER + TABLE_PADDING + CARD_PADDING
          
          
         if effectiveRowCount > 0 then
         if effectiveRowCount > 0 then
Line 257: 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 268: 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 282: 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, use 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 for extremely large cards that would dominate the layout
             table.insert(normalCards, item.card)
        local largestCard = regularCards[1]
        local secondLargestCard = regularCards[2]
       
        -- If the largest card is more than 50% of total content, we need special handling
         if largestCard and largestCard.estimatedSize > (totalSize * 0.5) then
             -- If we have multiple very large cards, distribute them across columns
            if secondLargestCard and secondLargestCard.estimatedSize > (totalSize * 0.3) then
                -- Multiple large cards - use all columns but with special placement
                optimalColumnCount = columnCount
             else
                -- One dominant card - use fewer columns for better balance
                optimalColumnCount = math.max(2, math.min(columnCount, #regularCards))
            end
         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 but preserve special cards
         -- Find columns with least content (excluding special cards initially)
         local newColumns = {}
         local availableColumns = {}
         local newColumnHeights = {}
         for i = 1, columnCount do
         local newSpecialCardHeights = {}
            -- Skip columns with special cards for initial extreme card placement
            if not ((i == 1 and introCard) or (i == columnCount and infoBoxCard)) then
                table.insert(availableColumns, {column = i, height = columnHeights[i]})
            end
         end
          
          
         for i = 1, optimalColumnCount do
         -- If all columns have special cards, include them
            newColumns[i] = {}
        if #availableColumns == 0 then
            newColumnHeights[i] = 0
            for i = 1, columnCount do
             newSpecialCardHeights[i] = 0
                table.insert(availableColumns, {column = i, height = columnHeights[i]})
             end
         end
         end
          
          
         -- Re-place special cards in the new column structure
         -- Sort by height ascending
         for _, special in ipairs(specialCards) do
         table.sort(availableColumns, function(a, b) return a.height < b.height end)
            local targetCol = math.min(special.targetColumn, optimalColumnCount)
            table.insert(newColumns[targetCol], special.card)
            newSpecialCardHeights[targetCol] = special.card.estimatedSize or 0
        end
          
          
         -- Add empty columns at the end
         -- Place extreme cards in shortest columns
         for i = optimalColumnCount + 1, columnCount do
         for i, extremeCard in ipairs(extremeCards) do
             newColumns[i] = {}
             if availableColumns[i] then
            newColumnHeights[i] = 0
                local targetColumn = availableColumns[i].column
            newSpecialCardHeights[i] = 0
                table.insert(columns[targetColumn], extremeCard)
                columnHeights[targetColumn] = columnHeights[targetColumn] + (extremeCard.estimatedSize or 0)
            end
         end
         end
       
        columns = newColumns
        columnHeights = newColumnHeights
        specialCardHeights = newSpecialCardHeights
     end
     end
      
      
     -- Enhanced distribution strategy for handling very large cards
     -- Phase 2: Distribute remaining cards using modified best-fit algorithm
     if #regularCards > 0 then
     for _, card in ipairs(normalCards) do
        local largestCard = regularCards[1]
        -- Calculate which column would result in best overall balance
        local totalRegularSize = 0
        local bestColumn = 1
        for _, card in ipairs(regularCards) do
         local bestScore = math.huge
            totalRegularSize = totalRegularSize + (card.estimatedSize or 0)
         end
          
          
         -- If we have one extremely large card (>60% of content), use special placement
         for i = 1, columnCount do
        if largestCard and largestCard.estimatedSize > (totalRegularSize * 0.6) then
            -- Simulate adding this card to column i
             -- Place the largest card in the shortest column
            local simulatedHeight = columnHeights[i] + (card.estimatedSize or 0)
             local shortestColumn = 1
           
             local shortestHeight = columnHeights[1]
             -- Calculate variance if we add to this column
             for i = 2, optimalColumnCount do
             local variance = 0
                 if columnHeights[i] < shortestHeight then
             local totalHeight = 0
                    shortestColumn = i
           
                    shortestHeight = columnHeights[i]
             for j = 1, columnCount do
                 end
                 local h = (j == i) and simulatedHeight or columnHeights[j]
                 totalHeight = totalHeight + h
             end
             end
              
              
             table.insert(columns[shortestColumn], largestCard)
             local avgHeight = totalHeight / columnCount
            columnHeights[shortestColumn] = columnHeights[shortestColumn] + (largestCard.estimatedSize or 0)
              
              
             -- Remove the largest card from the list
             for j = 1, columnCount do
            table.remove(regularCards, 1)
                local h = (j == i) and simulatedHeight or columnHeights[j]
                local diff = h - avgHeight
                variance = variance + (diff * diff)
            end
              
              
             -- Distribute remaining cards using round-robin to avoid clustering
             -- Prefer columns that minimize variance
            local currentColumn = 1
             if variance < bestScore then
             for _, card in ipairs(regularCards) do
                bestScore = variance
                -- Skip the column with the large card for the first few placements
                bestColumn = i
                if currentColumn == shortestColumn and #regularCards > optimalColumnCount then
            end
                    currentColumn = currentColumn + 1
        end
                    if currentColumn > optimalColumnCount then
       
                        currentColumn = 1
        -- Place card in best column
                    end
        table.insert(columns[bestColumn], card)
                end
        columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0)
               
    end
                table.insert(columns[currentColumn], card)
   
                columnHeights[currentColumn] = columnHeights[currentColumn] + (card.estimatedSize or 0)
    -- Phase 3: Post-optimization - try to swap cards between columns to improve balance
               
    -- This is a simple optimization pass that can significantly improve results
                currentColumn = currentColumn + 1
    local improved = true
                if currentColumn > optimalColumnCount then
    local iterations = 0
                    currentColumn = 1
    local maxIterations = 10
                 end
   
    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
         else
         end
            -- Standard greedy placement for balanced content
       
             for _, card in ipairs(regularCards) do
        -- Try to move a card from max to min column
                local bestColumn = 1
        if maxCol ~= minCol and maxHeight - minHeight > 100 then -- Significant imbalance
                 local bestHeight = columnHeights[1]
             -- Find a card in maxCol that would improve balance
                  
            for i = #columns[maxCol], 1, -1 do
                 -- Find the best column (shortest among active columns)
                 local card = columns[maxCol][i]
                for i = 2, optimalColumnCount do
                 -- Skip special cards
                    if columnHeights[i] < bestHeight then
                 if card.id ~= 'intro' and card.id ~= 'infoBox' then
                         bestColumn = i
                    local cardSize = card.estimatedSize or 0
                         bestHeight = columnHeights[i]
                    -- 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
               
                -- Place card and update height
                table.insert(columns[bestColumn], card)
                columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0)
             end
             end
         end
         end
    end
   
    -- Now add special card heights for final balance calculation
    -- This ensures special cards are aligned at the top but still count toward total column height
    for i = 1, columnCount do
        columnHeights[i] = columnHeights[i] + specialCardHeights[i]
     end
     end
      
      
Line 438: 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 631: 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 659: 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 724: 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 762: 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