Module:MasonryLayout: Difference between revisions

// via Wikitext Extension for VSCode
// via Wikitext Extension for VSCode
 
(29 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()
         -- Base height calculation
         -- Card padding constant (15px top + 15px bottom from CSS)
         local baseHeight = SIZE_ESTIMATES.BASE_CARD_HEIGHT
         local CARD_PADDING = 30
          
          
         -- Add height for table rows
         -- Special handling for intro cards (text content, not tables)
         local rowCount = cardData.rowCount or 0
         if cardData.contentType == 'intro' then
         local rowHeight = rowCount * SIZE_ESTIMATES.ROW_HEIGHT
            -- Intro is typically 2-3 lines of welcome text + card padding
            return 120 + CARD_PADDING -- Fixed height for consistency
         end
          
          
         -- Add bonus for long titles
         -- Special handling for infoBox
        local titleBonus = 0
         if cardData.contentType == 'infoBox' then
         if cardData.title and #cardData.title > 20 then
             -- InfoBox has fixed structure: header + 4-5 rows + card padding
             titleBonus = SIZE_ESTIMATES.TITLE_CHAR_FACTOR * (#cardData.title - 20)
            return 200 + CARD_PADDING -- Fixed height based on actual structure
         end
         end
          
          
         -- Add bonus for long content names
         -- Precise calculation for data tables
         local contentBonus = 0
        -- Based on actual MediaWiki table rendering measurements:
         if cardData.hasLongNames then
        local TABLE_HEADER = 45      -- Table header with title
             contentBonus = rowCount * 5 -- Extra height per row for long names
        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 contentHeight = TABLE_HEADER + TABLE_PADDING + CARD_PADDING
       
         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
          
          
         -- Mobile adjustments
         -- Add small buffer for margin/padding variations
         local mobileBonus = 0
         contentHeight = contentHeight + 15
       
        -- Mobile adjustments (slightly more spacing)
         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
         totalHeight = math.max(SIZE_ESTIMATES.MIN_CARD_HEIGHT, totalHeight)
         totalHeight = math.min(SIZE_ESTIMATES.MAX_CARD_HEIGHT, totalHeight)
          
          
         return totalHeight
         return math.floor(contentHeight)
     end)
     end)
end
end
Line 231: Line 235:
      
      
     return TemplateHelpers.withCache(cacheKey, function()
     return TemplateHelpers.withCache(cacheKey, function()
         -- Pre-allocate column arrays for performance
         return p.distributeCardsIntelligent(cards, columnCount)
         local columns = {}
    end)
         local columnHeights = {}
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
    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 infoBoxCard = nil
    local introCard = nil
   
    for _, card in ipairs(cards) do
        if card.id == 'infoBox' then
            infoBoxCard = card
        elseif card.id == 'intro' then
            introCard = card
        else
            table.insert(regularCards, card)
        end
    end
   
    -- Place intro and infoBox first for alignment
    if introCard then
        table.insert(columns[1], introCard)
        columnHeights[1] = introCard.estimatedSize or 0
    end
   
    if infoBoxCard then
        table.insert(columns[columnCount], infoBoxCard)
        columnHeights[columnCount] = infoBoxCard.estimatedSize or 0
    end
   
    -- Calculate total size and identify extreme cases
    local totalRegularSize = 0
    local cardSizes = {}
   
    for _, card in ipairs(regularCards) do
         local size = card.estimatedSize or 0
        totalRegularSize = totalRegularSize + size
        table.insert(cardSizes, {card = card, size = size})
    end
   
    -- Sort by size descending for optimal packing
    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
   
    -- Phase 1: Distribute extreme cards to minimize imbalance
    if #extremeCards > 0 then
        -- Find columns with least content (excluding special cards initially)
        local availableColumns = {}
         for i = 1, columnCount do
         for i = 1, columnCount do
             columns[i] = {}
             -- Skip columns with special cards for initial extreme card placement
            columnHeights[i] = 0
            if not ((i == 1 and introCard) or (i == columnCount and infoBoxCard)) then
                table.insert(availableColumns, {column = i, height = columnHeights[i]})
            end
         end
         end
          
          
         -- Create a copy of cards for sorting (don't modify original)
         -- If all columns have special cards, include them
         local sortedCards = {}
         if #availableColumns == 0 then
        for i, card in ipairs(cards) do
            for i = 1, columnCount do
            sortedCards[i] = card
                table.insert(availableColumns, {column = i, height = columnHeights[i]})
            end
         end
         end
          
          
         -- Sort cards by size (largest first) for better distribution
         -- Sort by height ascending
         table.sort(sortedCards, function(a, b)  
         table.sort(availableColumns, function(a, b) return a.height < b.height end)
            return (a.estimatedSize or 0) > (b.estimatedSize or 0)
        end)
          
          
         -- Greedy placement: always choose shortest column
         -- Place extreme cards in shortest columns
         for _, card in ipairs(sortedCards) do
        for i, extremeCard in ipairs(extremeCards) do
            local shortestColumn = 1
            if availableColumns[i] then
             local shortestHeight = columnHeights[1]
                local targetColumn = availableColumns[i].column
                table.insert(columns[targetColumn], extremeCard)
                columnHeights[targetColumn] = columnHeights[targetColumn] + (extremeCard.estimatedSize or 0)
            end
         end
    end
   
    -- Phase 2: Distribute remaining cards using modified best-fit algorithm
    for _, card in ipairs(normalCards) do
        -- Calculate which column would result in best overall balance
        local bestColumn = 1
        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
              
              
            -- Find shortest column (optimized loop)
             for j = 1, columnCount do
             for i = 2, columnCount do
                 local h = (j == i) and simulatedHeight or columnHeights[j]
                 if columnHeights[i] < shortestHeight then
                local diff = h - avgHeight
                    shortestColumn = i
                variance = variance + (diff * diff)
                    shortestHeight = columnHeights[i]
                end
             end
             end
              
              
             -- Place card and update height
             -- Prefer columns that minimize variance
            table.insert(columns[shortestColumn], card)
            if variance < bestScore then
            columnHeights[shortestColumn] = columnHeights[shortestColumn] + (card.estimatedSize or 0)
                bestScore = variance
                bestColumn = i
            end
        end
       
        -- Place card in best column
        table.insert(columns[bestColumn], card)
        columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0)
    end
   
    -- Phase 3: Post-optimization - try to swap cards between columns to improve balance
    -- This is a simple optimization pass that can significantly improve results
    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
         end
          
          
         return {
         -- Try to move a card from max to min column
            columns = columns,
        if maxCol ~= minCol and maxHeight - minHeight > 100 then -- Significant imbalance
            heights = columnHeights,
            -- Find a card in maxCol that would improve balance
            balance = calculateBalance(columnHeights)
            for i = #columns[maxCol], 1, -1 do
         }
                local card = columns[maxCol][i]
     end)
                -- 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
   
    return {
        columns = columns,
        heights = columnHeights,
        balance = calculateBalance(columnHeights),
        totalSize = totalRegularSize,
        extremeCards = #extremeCards,
         iterations = iterations
     }
end
end


Line 372: 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 396: 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 425: 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 454: 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 482: 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 511: Line 680:
      
      
     return table.concat(classes, ' ')
     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 {}
   
    -- 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
    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
   
    -- Branch based on render mode
    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