Jump to content

Module:MasonryLayout

Revision as of 00:48, 8 June 2025 by MarkWD (talk | contribs) (// via Wikitext Extension for VSCode)

Documentation for this module may be created at Module:MasonryLayout/doc

--[[
 * MasonryLayout.lua
 * Intelligent masonry layout system for content distribution
 * 
 * This module provides smart card distribution across columns for optimal visual balance.
 * Integrates with the Blueprint architecture and follows ICANNWiki performance patterns.
 *
 * Key Features:
 * - Content-aware size estimation
 * - Intelligent card distribution algorithm
 * - Responsive column management
 * - Aggressive caching for performance
 * - Blueprint integration
 * - Error handling integration
 *
 * Integration with other modules:
 * - ErrorHandling: All operations are protected with centralized error handling
 * - TemplateHelpers: Uses caching mechanisms and utility functions
 * - TemplateStructure: Integrates with block-based rendering
]]

local p = {}

-- ========== Constants as Upvalues ==========
local EMPTY_STRING = ''
local DEFAULT_COLUMNS = 3
local MOBILE_BREAKPOINT = 480
local TABLET_BREAKPOINT = 768

-- Size estimation constants (based on typical MediaWiki table rendering)
local SIZE_ESTIMATES = {
    BASE_CARD_HEIGHT = 120,      -- Base table overhead (header + borders + padding)
    HEADER_HEIGHT = 40,          -- Table header height
    ROW_HEIGHT = 25,             -- Average height per table row
    TITLE_CHAR_FACTOR = 1.2,     -- Additional height for long titles
    CONTENT_OVERFLOW_FACTOR = 15, -- Extra height for content overflow
    MIN_CARD_HEIGHT = 80,        -- Minimum card height
    MAX_CARD_HEIGHT = 800        -- Maximum card height (safety limit)
}

-- Column distribution weights for different screen sizes
local RESPONSIVE_CONFIG = {
    desktop = { columns = 3, minWidth = TABLET_BREAKPOINT + 1 },
    tablet = { columns = 2, minWidth = MOBILE_BREAKPOINT + 1, maxWidth = TABLET_BREAKPOINT },
    mobile = { columns = 1, maxWidth = MOBILE_BREAKPOINT }
}

-- ========== Required modules ==========
local ErrorHandling = require('Module:ErrorHandling')
local TemplateHelpers = require('Module:TemplateHelpers')
local NormalizationText = require('Module:NormalizationText')

-- ========== Module-level Caches ==========
local sizeCache = {}
local distributionCache = {}

-- ========== Utility Functions ==========

-- Generate a signature for a set of cards to use in cache keys
-- @param cards table Array of card objects
-- @return string A signature representing the card set
local function generateCardSignature(cards)
    if not cards or #cards == 0 then
        return 'empty'
    end
    
    local parts = {}
    for i, card in ipairs(cards) do
        parts[i] = string.format('%s:%d:%s', 
            card.id or 'unknown',
            card.estimatedSize or 0,
            card.contentType or 'default'
        )
    end
    
    return table.concat(parts, '|')
end

-- Calculate balance score for column heights (lower is better)
-- @param columnHeights table Array of column heights
-- @return number Balance score (0 = perfect balance)
local function calculateBalance(columnHeights)
    if not columnHeights or #columnHeights < 2 then
        return 0
    end
    
    local total = 0
    local count = #columnHeights
    
    -- Calculate average height
    for _, height in ipairs(columnHeights) do
        total = total + height
    end
    local average = total / count
    
    -- Calculate variance from average
    local variance = 0
    for _, height in ipairs(columnHeights) do
        local diff = height - average
        variance = variance + (diff * diff)
    end
    
    return variance / count
end

-- ========== Core Size Estimation ==========

-- Estimate the rendered height of a card based on its content
-- @param cardData table Card information: {title, rowCount, contentType, hasLongNames, isEmpty}
-- @param options table Options: {mobileMode, customFactors}
-- @return number Estimated height in pixels
function p.estimateCardSize(cardData, options)
    options = options or {}
    
    -- Handle empty cards
    if cardData.isEmpty then
        return 0
    end
    
    -- Generate cache key
    local cacheKey = TemplateHelpers.generateCacheKey(
        'masonryCardSize',
        cardData.title or '',
        cardData.rowCount or 0,
        cardData.contentType or 'default',
        cardData.hasLongNames and 'longNames' or 'shortNames',
        options.mobileMode and 'mobile' or 'desktop'
    )
    
    return TemplateHelpers.withCache(cacheKey, function()
        -- Base height calculation
        local baseHeight = SIZE_ESTIMATES.BASE_CARD_HEIGHT
        
        -- Add height for table rows
        local rowCount = cardData.rowCount or 0
        local rowHeight = rowCount * SIZE_ESTIMATES.ROW_HEIGHT
        
        -- Add bonus for long titles
        local titleBonus = 0
        if cardData.title and #cardData.title > 20 then
            titleBonus = SIZE_ESTIMATES.TITLE_CHAR_FACTOR * (#cardData.title - 20)
        end
        
        -- Add bonus for long content names
        local contentBonus = 0
        if cardData.hasLongNames then
            contentBonus = rowCount * 5 -- Extra height per row for long names
        end
        
        -- Mobile adjustments
        local mobileBonus = 0
        if options.mobileMode then
            mobileBonus = rowCount * 3 -- Slightly more height on mobile
        end
        
        -- Calculate total height
        local totalHeight = baseHeight + rowHeight + titleBonus + contentBonus + mobileBonus
        
        -- Apply bounds
        totalHeight = math.max(SIZE_ESTIMATES.MIN_CARD_HEIGHT, totalHeight)
        totalHeight = math.min(SIZE_ESTIMATES.MAX_CARD_HEIGHT, totalHeight)
        
        return totalHeight
    end)
end

-- Analyze SMW query results to estimate card content
-- @param queryResults table Results from SMW query
-- @param cardType string Type of card ('organizations', 'people', etc.)
-- @return table Card data for size estimation
function p.analyzeQueryResults(queryResults, cardType)
    if not queryResults or #queryResults == 0 then
        return {
            isEmpty = true,
            rowCount = 0,
            contentType = cardType
        }
    end
    
    local rowCount = #queryResults
    local hasLongNames = false
    local totalNameLength = 0
    
    -- Analyze content for long names
    for _, result in ipairs(queryResults) do
        local name = result.result or result[1] or ''
        totalNameLength = totalNameLength + #name
        if #name > 30 then
            hasLongNames = true
        end
    end
    
    local averageNameLength = totalNameLength / rowCount
    
    return {
        isEmpty = false,
        rowCount = rowCount,
        contentType = cardType,
        hasLongNames = hasLongNames or averageNameLength > 25,
        averageNameLength = averageNameLength
    }
end

-- ========== Smart Distribution Algorithm ==========

-- Distribute cards across columns for optimal balance
-- @param cards table Array of card objects with size estimates
-- @param columnCount number Target number of columns (default: 3)
-- @return table Distribution result: {columns, heights, balance}
function p.distributeCards(cards, columnCount)
    columnCount = columnCount or DEFAULT_COLUMNS
    
    -- Handle edge cases
    if not cards or #cards == 0 then
        local emptyColumns = {}
        local emptyHeights = {}
        for i = 1, columnCount do
            emptyColumns[i] = {}
            emptyHeights[i] = 0
        end
        return {
            columns = emptyColumns,
            heights = emptyHeights,
            balance = 0
        }
    end
    
    -- Generate cache key
    local cardSignature = generateCardSignature(cards)
    local cacheKey = TemplateHelpers.generateCacheKey('masonryDistribution', cardSignature, columnCount)
    
    return TemplateHelpers.withCache(cacheKey, function()
        return p.distributeCardsIntelligent(cards, columnCount)
    end)
end

-- Intelligent card distribution with balance optimization
-- @param cards table Array of card objects with size estimates
-- @param columnCount number Target number of columns
-- @return table Distribution result: {columns, heights, balance}
function p.distributeCardsIntelligent(cards, columnCount)
    -- Pre-allocate column arrays for performance
    local columns = {}
    local columnHeights = {}
    
    for i = 1, columnCount do
        columns[i] = {}
        columnHeights[i] = 0
    end
    
    -- Separate special positioning cards from regular cards
    local regularCards = {}
    local specialCards = {}
    
    for _, card in ipairs(cards) do
        if card.id == 'infoBox' then
            -- InfoBox always goes to rightmost column
            table.insert(specialCards, {card = card, targetColumn = columnCount})
        elseif card.id == 'intro' then
            -- Intro always goes to leftmost column
            table.insert(specialCards, {card = card, targetColumn = 1})
        else
            table.insert(regularCards, card)
        end
    end
    
    -- Place special cards first
    for _, special in ipairs(specialCards) do
        table.insert(columns[special.targetColumn], special.card)
        columnHeights[special.targetColumn] = columnHeights[special.targetColumn] + (special.card.estimatedSize or 0)
    end
    
    -- Sort regular cards by size (largest first) for better distribution
    table.sort(regularCards, function(a, b) 
        return (a.estimatedSize or 0) > (b.estimatedSize or 0) 
    end)
    
    -- Calculate total content size and average per column
    local totalSize = 0
    for _, card in ipairs(sortedCards) do
        totalSize = totalSize + (card.estimatedSize or 0)
    end
    local averageColumnHeight = totalSize / columnCount
    
    -- Smart distribution: check if we should use fewer columns for better balance
    local optimalColumnCount = columnCount
    
    -- If we have very few cards or very uneven sizes, consider using fewer columns
    if #sortedCards <= 2 then
        optimalColumnCount = math.min(#sortedCards, columnCount)
    else
        -- Check if largest card is more than 60% of total content
        local largestCard = sortedCards[1]
        if largestCard and largestCard.estimatedSize > (totalSize * 0.6) then
            -- Very unbalanced content - use fewer columns
            optimalColumnCount = math.max(2, math.min(columnCount, #sortedCards))
        end
    end
    
    -- Redistribute columns if we're using fewer
    if optimalColumnCount < columnCount then
        -- Reset columns for optimal count
        columns = {}
        columnHeights = {}
        for i = 1, optimalColumnCount do
            columns[i] = {}
            columnHeights[i] = 0
        end
        -- Add empty columns at the end
        for i = optimalColumnCount + 1, columnCount do
            columns[i] = {}
            columnHeights[i] = 0
        end
    end
    
    -- Greedy placement with balance checking
    for _, card in ipairs(sortedCards) do
        local bestColumn = 1
        local bestHeight = columnHeights[1]
        
        -- Find the best column (shortest among active columns)
        for i = 2, optimalColumnCount do
            if columnHeights[i] < bestHeight then
                bestColumn = i
                bestHeight = columnHeights[i]
            end
        end
        
        -- Place card and update height
        table.insert(columns[bestColumn], card)
        columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0)
    end
    
    return {
        columns = columns,
        heights = columnHeights,
        balance = calculateBalance(columnHeights),
        optimalColumnCount = optimalColumnCount,
        totalSize = totalSize
    }
end

-- ========== Blueprint Integration ==========

-- Create Blueprint-compatible block functions for masonry layout
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return table Blueprint block configuration
function p.createMasonryBlocks(cardDefinitions, options)
    options = options or {}
    
    return {
        masonryWrapperOpen = {
            feature = 'fullPage',
            render = function(template, args)
                return p.renderMasonryOpen(options)
            end
        },
        
        masonryContent = {
            feature = 'fullPage',
            render = function(template, args)
                return p.renderMasonryContent(template, args, cardDefinitions, options)
            end
        },
        
        masonryWrapperClose = {
            feature = 'fullPage',
            render = function(template, args)
                return p.renderMasonryClose()
            end
        }
    }
end

-- Render the opening masonry container
-- @param options table Layout options
-- @return string HTML for masonry container opening
function p.renderMasonryOpen(options)
    options = options or {}
    local cssClass = options.containerClass or 'country-hub-masonry-container'
    
    return string.format('<div class="%s">', cssClass)
end

-- Render the closing masonry container
-- @return string HTML for masonry container closing
function p.renderMasonryClose()
    return '</div>'
end

-- Render the main masonry content with intelligent distribution
-- @param template table Template object
-- @param args table Template arguments
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return string Complete masonry layout HTML
function p.renderMasonryContent(template, args, cardDefinitions, options)
    if not template._errorContext then
        template._errorContext = ErrorHandling.createContext("MasonryLayout")
    end
    
    return ErrorHandling.protect(
        template._errorContext,
        "renderMasonryContent",
        function()
            return p.renderMasonryContentInternal(template, args, cardDefinitions, options)
        end,
        EMPTY_STRING,
        template, args, cardDefinitions, options
    )
end

-- Internal masonry content rendering (protected by error handling)
-- @param template table Template object
-- @param args table Template arguments
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return string Complete masonry layout HTML
function p.renderMasonryContentInternal(template, args, cardDefinitions, options)
    options = options or {}
    local columnCount = options.columns or DEFAULT_COLUMNS
    
    -- Build cards with content analysis
    local cards = {}
    local cardIndex = 1
    
    for _, cardDef in ipairs(cardDefinitions) do
        -- Check if this card's feature is enabled
        if template.features[cardDef.feature] then
            -- Get the block renderer for this card
            local renderer = template._blocks and template._blocks[cardDef.blockId]
            if renderer and renderer.render then
                -- Render the card content
                local cardContent = renderer.render(template, args)
                
                if cardContent and cardContent ~= EMPTY_STRING then
                    -- Analyze the content for size estimation
                    local cardData = p.analyzeCardContent(cardContent, cardDef)
                    cardData.id = cardDef.blockId
                    cardData.title = cardDef.title or cardDef.blockId
                    cardData.content = cardContent
                    cardData.estimatedSize = p.estimateCardSize(cardData, options)
                    
                    cards[cardIndex] = cardData
                    cardIndex = cardIndex + 1
                end
            end
        end
    end
    
    -- Distribute cards across columns
    local distribution = p.distributeCards(cards, columnCount)
    
    -- Render the distributed layout
    return p.renderDistributedLayout(distribution, options)
end

-- Analyze rendered card content to extract size information
-- @param cardContent string Rendered HTML content
-- @param cardDef table Card definition
-- @return table Card data for size estimation
function p.analyzeCardContent(cardContent, cardDef)
    if not cardContent or cardContent == EMPTY_STRING then
        return {
            isEmpty = true,
            rowCount = 0,
            contentType = cardDef.blockId
        }
    end
    
    -- Count table rows in the content (look for <tr> tags or table row patterns)
    local _, trCount = cardContent:gsub('<tr[^>]*>', '')
    local _, wikiRowCount = cardContent:gsub('|-', '')
    local rowCount = math.max(trCount, wikiRowCount)
    
    -- If no explicit rows found, estimate based on content length
    if rowCount == 0 and #cardContent > 100 then
        -- Rough estimation: every ~200 characters might be a row
        rowCount = math.ceil(#cardContent / 200)
    end
    
    -- Check for long content (rough heuristic)
    local hasLongNames = #cardContent > 1000 or cardContent:find('[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+')
    
    return {
        isEmpty = false,
        rowCount = math.max(1, rowCount), -- At least 1 row if content exists
        contentType = cardDef.blockId,
        hasLongNames = hasLongNames,
        contentLength = #cardContent
    }
end

-- Render the final distributed layout
-- @param distribution table Distribution result from distributeCards
-- @param options table Layout options
-- @return string Complete HTML for the distributed layout
function p.renderDistributedLayout(distribution, options)
    options = options or {}
    local columnClass = options.columnClass or 'country-hub-masonry-column'
    local cardClass = options.cardClass or 'country-hub-masonry-card'
    
    local columns = distribution.columns
    local columnCount = #columns
    
    if columnCount == 0 then
        return EMPTY_STRING
    end
    
    -- Build HTML for each column
    local columnHtml = {}
    
    for i = 1, columnCount do
        local columnCards = columns[i]
        local cardHtml = {}
        
        -- Render cards in this column
        for j, card in ipairs(columnCards) do
            cardHtml[j] = string.format(
                '<div class="%s" data-card-id="%s">%s</div>',
                cardClass,
                card.id or 'unknown',
                card.content or EMPTY_STRING
            )
        end
        
        -- Wrap column
        columnHtml[i] = string.format(
            '<div class="%s" data-column="%d">%s</div>',
            columnClass,
            i,
            table.concat(cardHtml, '\n')
        )
    end
    
    return table.concat(columnHtml, '\n')
end

-- ========== Responsive Utilities ==========

-- Get responsive column count based on screen size
-- @param screenWidth number Screen width in pixels
-- @return number Appropriate column count
function p.getResponsiveColumns(screenWidth)
    if screenWidth <= MOBILE_BREAKPOINT then
        return 1
    elseif screenWidth <= TABLET_BREAKPOINT then
        return 2
    else
        return 3
    end
end

-- Generate responsive CSS classes
-- @param options table Layout options
-- @return string CSS classes for responsive behavior
function p.generateResponsiveClasses(options)
    options = options or {}
    local baseClass = options.baseClass or 'country-hub-masonry'
    
    local classes = {baseClass}
    
    if options.mobileColumns then
        table.insert(classes, baseClass .. '-mobile-' .. options.mobileColumns)
    end
    
    if options.tabletColumns then
        table.insert(classes, baseClass .. '-tablet-' .. options.tabletColumns)
    end
    
    if options.desktopColumns then
        table.insert(classes, baseClass .. '-desktop-' .. options.desktopColumns)
    end
    
    return table.concat(classes, ' ')
end

-- ========== Intelligent Layout Rendering ==========

-- Main render function for intelligent masonry layout
-- This is the core function that coordinates content rendering, analysis, and distribution
-- @param template table Template object with features and configuration
-- @param args table Template arguments
-- @param config table Configuration with cardDefinitions, options, and blockRenderers
-- @return string Complete masonry layout HTML
function p.renderIntelligentLayout(template, args, config)
    -- Create render-time error context (Blueprint pattern)
    local errorContext = ErrorHandling.createContext("MasonryLayout")
    
    return ErrorHandling.protect(
        errorContext,
        "renderIntelligentLayout",
        function()
            return p.renderIntelligentLayoutInternal(template, args, config, errorContext)
        end,
        EMPTY_STRING,
        template, args, config
    )
end

-- Internal implementation of intelligent layout rendering
-- @param template table Template object
-- @param args table Template arguments  
-- @param config table Configuration object
-- @param errorContext table Error context for protected operations
-- @return string Complete masonry layout HTML
function p.renderIntelligentLayoutInternal(template, args, config, errorContext)
    local cardDefinitions = config.cardDefinitions or {}
    local options = config.options or {}
    local blockRenderers = config.blockRenderers or {}
    
    local columnCount = options.columns or DEFAULT_COLUMNS
    
    -- Build cards with render-time content generation
    local cards = {}
    local cardIndex = 1
    
    for _, cardDef in ipairs(cardDefinitions) do
        -- Check if this card's feature is enabled
        if template.features[cardDef.feature] then
            -- Get the block renderer for this card
            local renderer = blockRenderers[cardDef.blockId]
            if renderer and renderer.render then
                -- Render the card content at render-time (not configuration-time)
                local cardContent = ErrorHandling.protect(
                    errorContext,
                    "renderCard_" .. cardDef.blockId,
                    function()
                        return renderer.render(template, args)
                    end,
                    EMPTY_STRING,
                    template, args
                )
                
                if cardContent and cardContent ~= EMPTY_STRING then
                    -- Analyze the content for size estimation
                    local cardData = p.analyzeCardContent(cardContent, cardDef)
                    cardData.id = cardDef.blockId
                    cardData.title = cardDef.title or cardDef.blockId
                    cardData.content = cardContent
                    cardData.estimatedSize = p.estimateCardSize(cardData, options)
                    
                    cards[cardIndex] = cardData
                    cardIndex = cardIndex + 1
                end
            end
        end
    end
    
    -- Distribute cards across columns
    local distribution = p.distributeCards(cards, columnCount)
    
    -- Render the complete masonry layout
    local containerClass = options.containerClass or 'country-hub-masonry-container'
    local masonryHtml = string.format('<div class="%s">', containerClass)
    masonryHtml = masonryHtml .. p.renderDistributedLayout(distribution, options)
    masonryHtml = masonryHtml .. '</div>'
    
    return masonryHtml
end

-- ========== Debug and Utilities ==========

-- Get distribution statistics for debugging
-- @param distribution table Distribution result
-- @return table Statistics about the distribution
function p.getDistributionStats(distribution)
    if not distribution or not distribution.columns then
        return {
            columnCount = 0,
            totalCards = 0,
            balance = 0,
            heights = {}
        }
    end
    
    local totalCards = 0
    for _, column in ipairs(distribution.columns) do
        totalCards = totalCards + #column
    end
    
    return {
        columnCount = #distribution.columns,
        totalCards = totalCards,
        balance = distribution.balance or 0,
        heights = distribution.heights or {},
        averageHeight = distribution.heights and (#distribution.heights > 0) and 
                       (table.concat(distribution.heights, '+') / #distribution.heights) or 0
    }
end

-- Clear caches (for debugging/testing)
function p.clearCaches()
    sizeCache = {}
    distributionCache = {}
    -- Also clear TemplateHelpers cache if needed
end

return p