Module:MasonryLayout

Revision as of 00:08, 8 June 2025 by MarkWD (talk | contribs) (// via Wikitext Extension for VSCode)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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()
        -- Pre-allocate column arrays for performance
        local columns = {}
        local columnHeights = {}
        
        for i = 1, columnCount do
            columns[i] = {}
            columnHeights[i] = 0
        end
        
        -- Create a copy of cards for sorting (don't modify original)
        local sortedCards = {}
        for i, card in ipairs(cards) do
            sortedCards[i] = card
        end
        
        -- Sort cards by size (largest first) for better distribution
        table.sort(sortedCards, function(a, b) 
            return (a.estimatedSize or 0) > (b.estimatedSize or 0) 
        end)
        
        -- Greedy placement: always choose shortest column
        for _, card in ipairs(sortedCards) do
            local shortestColumn = 1
            local shortestHeight = columnHeights[1]
            
            -- Find shortest column (optimized loop)
            for i = 2, columnCount do
                if columnHeights[i] < shortestHeight then
                    shortestColumn = i
                    shortestHeight = columnHeights[i]
                end
            end
            
            -- Place card and update height
            table.insert(columns[shortestColumn], card)
            columnHeights[shortestColumn] = columnHeights[shortestColumn] + (card.estimatedSize or 0)
        end
        
        return {
            columns = columns,
            heights = columnHeights,
            balance = calculateBalance(columnHeights)
        }
    end)
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 (rough estimation)
    local _, rowCount = cardContent:gsub('|-', '')
    
    -- Check for long content (rough heuristic)
    local hasLongNames = #cardContent > 1000 or cardContent:find('%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w%w')
    
    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

-- ========== 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