Module:BubbleGraph

From IdleOn MMO Wiki
Revision as of 04:04, 2 January 2025 by BHY4A (talk | contribs)

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

local BubbleGraph = {}

BubbleGraph.bonusCalculations = {
    add = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1
    end,
    decay = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return (level * x1) / (level + x2)
    end,
    intervalAdd = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x1 + math.floor(level / x2)
    end,
    decayMulti = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return 1 + (level * x1) / (level + x2)
    end,
    bigBase = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x1 + x2 * level
    end,
    addLower = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x1 + x2 * (level + 1)
    end,
    decayLower = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2)
    end,
    decayMultiLower = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2)
    end,
    bigBaseLower = function(_, _, x2)
        if not x2 then
            return 0
        end
        return x2 or 0
    end,
    intervalAddLower = function(level, _, x2)
        if not x2 then
            return 0
        end
        return math.max(math.floor((level + 1) / x2), 0) - math.max(math.floor(level / x2), 0)
    end,
    reduce = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x1 - x2 * level
    end,
    reduceLower = function(level, x1, x2)
        if not x2 then
            return 0
        end
        return x1 - x2 * (level + 1)
    end
}

BubbleGraph.DISPLAY_MODES = {
    STANDARD = "standard",
    EFFICIENCY = "efficiency",
    LOGARITHMIC = "logarithmic",
    PERCENTAGE = "percentage",
    DELTA = "delta",
    RELATIVE = "relative",
    CUMULATIVE = "cumulative",
    NORMALIZED = "normalized"
}

local floor = math.floor
local format = string.format
local max = math.max
local min = math.min
local abs = math.abs

local function round(num, places)
    local mult = 10 ^ (places or 0)
    return floor(num * mult + 0.5) / mult
end

local function roundFormat(num)
    local str = tostring(num)
    return str:find("%.") and str:gsub("0+$", ""):gsub("%.$", "") or str
end

local CONSTANTS = {
    MAX_LEVELS = 1000,
    STEP = 20,
    GRAPH_WIDTH = 1000,
    GRAPH_HEIGHT = 250,
    DECIMAL_PLACES = 3,
    FONT_FAMILY = "Idleon",
    COLUMN_MIN_WIDTH = 2,
    MAJOR_GRID_LINES = 10,
    SHOW_GRID = true,
    TOOLTIP_WIDTH = 150,
    TOOLTIP_PADDING = 5,
    TEXT_SHADOW = "-.065em 0 #000, 0 .065em #000, .065em 0 #000, 0 -.065em #000",
    THRESHOLD_HEIGHT = 3,
    MIN_TEXT_HEIGHT = 20,
    LABEL_BOTTOM_OFFSET = 20,
    Y_AXIS_WIDTH = 40,
    FONT_SIZES = {
        TITLE = 19,
        LABEL = 10,
        Y_AXIS = 12
    },
    COLORS = {
        THRESHOLD = "#ff0000",
        TOOLTIP_BG = "#000142cc",
        TOOLTIP_BORDER = "#000142",
        COLUMN_BORDER = "#000",
        TEXT = "#fff",
        GRID_LINE = "#00000033",
        GRID_MAJOR = "#00000033"
    },
    Z_INDEX = {
        GRID = 1,
        GRAPH = 2,
        Y_AXIS = 3,
        THRESHOLD = 4,
        TOOLTIP = 10
    }
}

CONSTANTS.FORMATS = {
    RELATIVE = {
        PREFIX = "",
        SUFFIX = "x"
    },
    CUMULATIVE = {
        PREFIX = "Σ ",
        SUFFIX = ""
    },
    NORMALIZED = {
        PREFIX = "",
        SUFFIX = "%"
    }
}

local function formatValueByMode(value, displayMode)
    local format = CONSTANTS.FORMATS[displayMode] or {PREFIX = "", SUFFIX = ""}
    local formattedValue = roundFormat(round(value, CONSTANTS.DECIMAL_PLACES))
    return format.PREFIX .. formattedValue .. format.SUFFIX
end

local bubbleColors = {
    orange = {
        columnStart = "#ffb74dff",
        columnEnd = "#d84315ff",
        backgroundStart = "#fff3e0cc",
        backgroundEnd = "#f57c00cc"
    },
    green = {
        columnStart = "#9ccc65ff",
        columnEnd = "#2e7d32ff",
        backgroundStart = "#f1f8e9cc",
        backgroundEnd = "#43a047cc"
    },
    purple = {
        columnStart = "#ba68c8ff",
        columnEnd = "#4527a0ff",
        backgroundStart = "#f3e5f5cc",
        backgroundEnd = "#8e24aacc"
    },
    yellow = {
        columnStart = "#ffd54fff",
        columnEnd = "#ff8f00ff",
        backgroundStart = "#fffde7cc",
        backgroundEnd = "#ffc107cc",
        image = "YellowBubble"
    },
    other = {
        columnStart = "#ab47bcff",
        columnEnd = "#6a1b9aff",
        backgroundStart = "#f3e5f5cc",
        backgroundEnd = "#8e24aacc"
    }
}

local function sanitizeInput(text)
    return text:gsub(
        '[<>&"\']',
        {
            ["<"] = "&lt;",
            [">"] = "&gt;",
            ["&"] = "&amp;",
            ['"'] = "&quot;",
            ["'"] = "&#39;"
        }
    )
end

local function getThresholdLevel(x2)
    if not x2 then
        return 0
    end
    return x2 ~= 0 and x2 * 90 / (100 - 90) or CONSTANTS.MAX_LEVELS
end

local function hexToRgb(hex)
    local r = tonumber(hex:sub(2, 3), 16)
    local g = tonumber(hex:sub(4, 5), 16)
    local b = tonumber(hex:sub(6, 7), 16)
    local a = tonumber(hex:sub(8, 9) or "ff", 16)
    return {r, g, b, a}
end

local function interpolateColor(color1, color2, ratio)
    local rgb1, rgb2 = hexToRgb(color1), hexToRgb(color2)
    local interpolatedRgb = {}
    for i = 1, 4 do
        interpolatedRgb[i] = floor(rgb1[i] + ratio * (rgb2[i] - rgb1[i]))
    end
    return format("#%02x%02x%02x%02x", interpolatedRgb[1], interpolatedRgb[2], interpolatedRgb[3], interpolatedRgb[4])
end

local function formatDescription(description, realBonus)
    return description:gsub(
        "{",
        format(
            '<span class="bonus-value" style="color: %s; text-shadow: %s; font-size: 1.2em;">%s</span>',
            CONSTANTS.COLORS.THRESHOLD,
            CONSTANTS.TEXT_SHADOW,
            roundFormat(round(realBonus, CONSTANTS.DECIMAL_PLACES))
        )
    )
end

function BubbleGraph.calculateBonus(func, level, x1, x2)
    if not func or not BubbleGraph.bonusCalculations[func] then
        return 0
    end
    local calcFunc = BubbleGraph.bonusCalculations[func]
    return round(calcFunc(level, x1, x2), CONSTANTS.DECIMAL_PLACES)
end

local function calculateUtility(level, x1, x2, func)
    local actualMaxLevel = CONSTANTS.MAX_LEVELS

    if not x1 or not x2 then
        return 0
    end

    if func == "decayMulti" then
        local current = 1 + (level * x1) / (level + x2)
        local limit = 1 + x1
        local base = 1
        return ((current - base) / (limit - base)) * 100
    elseif func == "decay" then
        local current = (level * x1) / (level + x2)
        local limit = x1
        return (current / limit) * 100
    elseif func == "add" then
        local current = x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1
        local maxValue =
            x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (actualMaxLevel - 1)) / (x1 / x2)) * actualMaxLevel * x1 or
            actualMaxLevel * x1
        return (current / maxValue) * 100
    elseif func == "intervalAdd" then
        local current = x1 + floor(level / x2)
        local maxIntervals = floor(actualMaxLevel / x2)
        local maxValue = x1 + maxIntervals
        return (current / maxValue) * 100
    elseif func == "bigBase" then
        local current = x1 + x2 * level
        local maxValue = x1 + x2 * actualMaxLevel
        return (current / maxValue) * 100
    elseif func == "addLower" then
        local current = x1 + x2 * (level + 1)
        local maxValue = x1 + x2 * (actualMaxLevel + 1)
        return (current / maxValue) * 100
    elseif func == "decayLower" or func == "decayMultiLower" then
        local current = x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2)
        local limit = x1 / x2
        return (current / limit) * 100
    elseif func == "bigBaseLower" then
        return 100
    elseif func == "intervalAddLower" then
        local current = max(floor((level + 1) / x2), 0) - max(floor(level / x2), 0)
        local maxIntervals = max(floor((actualMaxLevel + 1) / x2), 0) - max(floor(actualMaxLevel / x2), 0)
        return current > 0 and 100 or 0
    elseif func == "reduce" then
        local current = x1 - x2 * level
        local minValue = x1 - x2 * actualMaxLevel
        local maxValue = x1
        return 100 - ((current - minValue) / (maxValue - minValue)) * 100
    elseif func == "reduceLower" then
        local current = x1 - x2 * (level + 1)
        local minValue = x1 - x2 * (actualMaxLevel + 1)
        local maxValue = x1 - x2
        return 100 - ((current - minValue) / (maxValue - minValue)) * 100
    end
    return 0
end

local function calculateEfficiencyThresholdLevel(x1, x2, func, targetEfficiency)
    local minLevel = 1
    local maxLevel = 100000
    local tolerance = 0.0001
    if not x2 then
        return 0
    end

    while minLevel < maxLevel do
        local level = floor((minLevel + maxLevel) / 2)
        local utility = calculateUtility(level, x1, x2, func)

        if abs(utility - targetEfficiency) < tolerance then
            return level
        elseif utility < targetEfficiency then
            minLevel = level + 1
        else
            maxLevel = level - 1
        end
    end

    return minLevel
end

local function addThresholdAndEfficiencyIndicators(column, point, x2)
    if not column or not point or not point.bonus then
        return column
    end

    local thresholdLevel = x2 ~= 0 and x2 * 90 / (100 - 90) or CONSTANTS.MAX_LEVELS

    if point.efficiency then
        local zoneClass = "efficiency-zone"
        if point.efficiency > 90 then
            column:tag("div"):addClass("efficiency-zone-high"):css(
                {
                    height = "100%",
                    opacity = "0.1"
                }
            )
        elseif point.efficiency > 75 then
            column:tag("div"):addClass("efficiency-zone-medium"):css(
                {
                    height = "100%",
                    opacity = "0.1"
                }
            )
        else
            column:tag("div"):addClass("efficiency-zone-low"):css(
                {
                    height = "100%",
                    opacity = "0.1"
                }
            )
        end
    end

    return column
end

local function calculateGrowthAnalysis(points)
    if not points or #points < 2 then
        return {
            rates = {},
            thresholds = {
                average = 0,
                significant = 0,
                critical = 0
            }
        }
    end

    local rates = {}
    local sum = 0
    local count = 0

    for i = 2, #points do
        local prevBonus = points[i - 1].bonus
        if prevBonus ~= 0 then
            local rate = (points[i].bonus - prevBonus) / prevBonus
            table.insert(rates, rate)
            sum = sum + rate
            count = count + 1
        end
    end

    local mean = count > 0 and sum / count or 0

    return {
        rates = rates,
        thresholds = {
            average = mean,
            significant = mean * 1.5,
            critical = mean * 2
        }
    }
end

local function calculateAverage(points)
    local sum = 0
    for _, point in ipairs(points) do
        sum = sum + point.bonus
    end
    return sum / #points
end

local function calculateVariance(points)
    local avg = calculateAverage(points)
    local sum = 0
    for _, point in ipairs(points) do
        sum = sum + (point.bonus - avg) ^ 2
    end
    return sum / #points
end

local function calculateRelativeValues(points, baseValue)
    if not points or #points == 0 then
        return {}
    end
    baseValue = baseValue or points[1].bonus

    local relativePoints = {}
    for _, point in ipairs(points) do
        local relativeCopy = {}
        for k, v in pairs(point) do
            relativeCopy[k] = v
        end
        relativeCopy.bonus = point.bonus / baseValue
        relativeCopy.formattedBonus = roundFormat(round(relativeCopy.bonus, CONSTANTS.DECIMAL_PLACES))
        table.insert(relativePoints, relativeCopy)
    end
    return relativePoints
end

local function calculateCumulativeValues(points)
    if not points or #points == 0 then
        return {}
    end

    local cumulativePoints = {}
    local sum = 0
    for _, point in ipairs(points) do
        local cumulativeCopy = {}
        for k, v in pairs(point) do
            cumulativeCopy[k] = v
        end
        sum = sum + point.bonus
        cumulativeCopy.bonus = sum
        cumulativeCopy.formattedBonus = roundFormat(round(sum, CONSTANTS.DECIMAL_PLACES))
        table.insert(cumulativePoints, cumulativeCopy)
    end
    return cumulativePoints
end

local function calculateNormalizedValues(points)
    if not points or #points == 0 then
        return {}
    end

    local minVal = math.huge
    local maxVal = -math.huge
    for _, point in ipairs(points) do
        minVal = math.min(minVal, point.bonus)
        maxVal = math.max(maxVal, point.bonus)
    end

    local range = maxVal - minVal
    local normalizedPoints = {}

    for _, point in ipairs(points) do
        local normalizedCopy = {}
        for k, v in pairs(point) do
            normalizedCopy[k] = v
        end
        normalizedCopy.bonus = range ~= 0 and (point.bonus - minVal) / range or 0
        normalizedCopy.formattedBonus = roundFormat(round(normalizedCopy.bonus, CONSTANTS.DECIMAL_PLACES))
        table.insert(normalizedPoints, normalizedCopy)
    end
    return normalizedPoints
end

function BubbleGraph.renderGrid(width, height, majorLines)
    local grid =
        mw.html.create("div"):css(
        {
            position = "absolute",
            top = "0",
            left = "0",
            width = width .. "px",
            height = height .. "px",
            ["pointer-events"] = "none",
            ["z-index"] = CONSTANTS.Z_INDEX.GRID,
            ["background-image"] = format(
                "linear-gradient(to right, %s 1px, transparent 1px), linear-gradient(to bottom, %s 1px, transparent 1px)",
                CONSTANTS.COLORS.GRID_LINE,
                CONSTANTS.COLORS.GRID_LINE
            ),
            ["background-size"] = format("%dpx 100%%, 100%% %dpx", width / majorLines, height / majorLines)
        }
    )

    for i = 0, majorLines do
        local position = (i / majorLines) * 100

        grid:tag("div"):css(
            {
                position = "absolute",
                left = position .. "%",
                top = "0",
                height = "100%",
                width = "1px",
                ["background-color"] = CONSTANTS.COLORS.GRID_MAJOR
            }
        )
    end

    return grid
end

local function createGraphContainer(width, height, options)
    local container =
        mw.html.create("div"):addClass("bubble-graph-container"):attr("role", "img"):attr("aria-label"):attr(
        "tabindex",
        "0"
    ):css(
        {
            width = width .. "px",
            height = height .. "px",
            position = "relative",
            ["font-family"] = CONSTANTS.FONT_FAMILY,
            ["text-align"] = "center",
            ["padding-bottom"] = "50px",
            ["padding-left"] = CONSTANTS.Y_AXIS_WIDTH .. "px"
        }
    )

    return container
end

local function createTooltipText(point, options, allPoints)
    local leftSection = {
        format("Level: %s", tostring(point.level))
    }

    local rightSection = {}

    if options.showMetrics then
        local avg = calculateAverage(allPoints)
        local var = calculateVariance(allPoints)

        table.insert(rightSection, format("Average: %.2f", avg))
        table.insert(rightSection, format("Variance: %.2f", var))

        if #allPoints > 1 then
            local growthAnalysis = calculateGrowthAnalysis(allPoints)
            local pointIndex
            for i, p in ipairs(allPoints) do
                if p.level == point.level then
                    pointIndex = i - 1
                    break
                end
            end
            if pointIndex and pointIndex > 0 and growthAnalysis.rates[pointIndex] then
                table.insert(rightSection, format("Growth Rate: %.2f%%", growthAnalysis.rates[pointIndex] * 100))
            end
        end
    end

    if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
        table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus, "RELATIVE")))
    elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
        table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus, "CUMULATIVE")))
    elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
        table.insert(leftSection, format("Value: %s", formatValueByMode(point.bonus * 100, "NORMALIZED")))
    else
        table.insert(leftSection, format("Value: %s", point.formattedBonus))
    end

    if options.showAnalysis then
        if point.efficiency then
            table.insert(leftSection, format("Efficiency: %.1f%%", point.efficiency))
        end

        if point.bonusGain then
            local sign = point.bonusGain > 0 and "+" or ""
            table.insert(rightSection, format("Growth: %s%.5f", sign, point.bonusGain))
        end
    end

    local tooltipHTML =
        format(
        [[
        <div class="tooltip-container">
            <div class="tooltip-top">
                <div class="tooltip-left">%s</div>
                <div class="tooltip-right">%s</div>
            </div>
            <div class="tooltip-bottom">%s</div>
        </div>
    ]],
        table.concat(leftSection, "<br>"),
        table.concat(rightSection, "<br>"),
        format('<span class="description">%s</span>', point.formattedDescription)
    )

    return tooltipHTML
end

local function createColumn(point, maxBonus, options, scheme, points)
    local height
    local displayValue

    if options.displayMode == BubbleGraph.DISPLAY_MODES.LOGARITHMIC then
        if point.bonus > 0 and maxBonus > 0 then
            local scale = 0.25
            local logValue = math.log(point.bonus + 1)
            local logMax = math.log(maxBonus + 1)
            height = math.floor((logValue / logMax) * options.graphHeight * scale)
            height = math.min(height, options.graphHeight)
            height = math.max(height, 1)
        else
            height = 0
        end
    elseif options.displayMode == BubbleGraph.DISPLAY_MODES.PERCENTAGE then
        height = floor((point.bonus / maxBonus) * 100 * (options.graphHeight / 100))
        displayValue = format("%.1f%%", (point.bonus / maxBonus) * 100)
    elseif options.displayMode == BubbleGraph.DISPLAY_MODES.DELTA then
        height = floor((abs(point.bonusGain or 0) / maxBonus) * options.graphHeight)
    elseif options.displayMode == "efficiency" then
        height = point.relativeHeight * options.graphHeight
    else
        height = floor((point.bonus / maxBonus) * options.graphHeight)
    end

    if not displayValue then
        if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
            displayValue = formatValueByMode(point.bonus, "RELATIVE")
        elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
            displayValue = formatValueByMode(point.bonus, "CUMULATIVE")
        elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
            displayValue = formatValueByMode(point.bonus * 100, "NORMALIZED")
        else
            displayValue = point.formattedBonus
        end
    end

    height = floor(height)

    local column =
        mw.html.create("div"):addClass("bubble-graph-column"):attr("role", "gridcell"):attr(
        "aria-label",
        format("Level %s, Value %s", point.level, point.formattedBonus)
    ):css(
        {
            flex = "1",
            height = height .. "px",
            position = "relative",
            ["min-width"] = CONSTANTS.COLUMN_MIN_WIDTH .. "px"
        }
    )

    local color
    if options.displayMode == "efficiency" then
        local ratio = point.relativeHeight
        color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio)
    else
        local thresholdLevel = getThresholdLevel(options.x2)
        local maxHeight = options.graphHeight
        local heightRatio = height / maxHeight

        if point.level <= thresholdLevel then
            color = interpolateColor(scheme.columnStart, scheme.columnEnd, heightRatio)
        else
            color = interpolateColor(scheme.columnEnd, scheme.columnStart, heightRatio)
        end
    end
    column:css("background-color", color)

    local tooltipText = createTooltipText(point, options, points)

    local tooltip =
        mw.html.create("div"):addClass("bubble-graph-tooltip"):css(
        {
            ["background-color"] = CONSTANTS.COLORS.TOOLTIP_BG,
            border = format("1px solid %s", CONSTANTS.COLORS.TOOLTIP_BORDER),
            padding = CONSTANTS.TOOLTIP_PADDING .. "px",
            color = CONSTANTS.COLORS.TEXT,
            ["text-align"] = "center",
            ["z-index"] = CONSTANTS.Z_INDEX.TOOLTIP,
            width = CONSTANTS.TOOLTIP_WIDTH .. "px",
            bottom = floor(height / 5) .. "px",
            ["pointer-events"] = "none",
            left = point.ratio * 100 .. "%",
            transform = format("translate(-%d%%, 0)", point.ratio * 100)
        }
    ):wikitext(tooltipText)

    column:node(tooltip)

    if point.isThreshold then
        column:tag("div"):css(
            {
                position = "absolute",
                top = "-5px",
                width = "100%",
                height = CONSTANTS.THRESHOLD_HEIGHT .. "px",
                ["background-color"] = CONSTANTS.COLORS.THRESHOLD,
                ["z-index"] = CONSTANTS.Z_INDEX.THRESHOLD
            }
        )
    end

    if point.formattedBonus ~= "0" then
        local valueSpan =
            mw.html.create("span"):addClass("bubble-graph-value"):css(
            {
                position = "absolute",
                color = CONSTANTS.COLORS.TEXT,
                ["text-shadow"] = CONSTANTS.TEXT_SHADOW,
                ["white-space"] = "nowrap"
            }
        ):wikitext(point.formattedBonus)

        local textLength = #point.formattedBonus * 8
        local padding = 5
        local minHeight = max(CONSTANTS.MIN_TEXT_HEIGHT, textLength + padding * 2)

        local actualHeight = height
        if options.displayMode == "efficiency" then
            actualHeight = floor(point.relativeHeight * options.graphHeight)
        end

        if actualHeight < minHeight then
            valueSpan:css(
                {
                    bottom = "100%",
                    left = "50%",
                    transform = "translate(-50%, -2px) rotate(-90deg)"
                }
            )
        else
            local textYPos = min(actualHeight / 2, actualHeight - textLength / 2 - padding)
            textYPos = max(textLength / 2 + padding, textYPos)

            valueSpan:css(
                {
                    bottom = textYPos .. "px",
                    left = "50%",
                    transform = "translate(-50%, 50%) rotate(-90deg)"
                }
            )
        end
        column:node(valueSpan)
    end

    if point.showLabel then
        local labelText =
            options.displayMode == "efficiency" and tostring(point.efficiency) .. "%" or tostring(point.level)

        column:tag("div"):css(
            {
                position = "absolute",
                bottom = -CONSTANTS.LABEL_BOTTOM_OFFSET .. "px",
                width = "100%",
                ["text-align"] = "center",
                ["font-size"] = CONSTANTS.FONT_SIZES.LABEL .. "px",
                ["z-index"] = CONSTANTS.Z_INDEX.GRAPH,
                ["text-wrap"] = "nowrap"
            }
        ):wikitext(labelText)
    end

    return column
end

local function generateGraphPoints(x1, x2, func, maxLevels, step, options)
    local maxBonus = 0
    local minLevel = max(1, options.minLevels or 1)
    local displayLevels = {}
    local thresholdLevel = floor(getThresholdLevel(x2))

    local baseLevel = minLevel - (minLevel % 100)
    if minLevel % 100 ~= 0 then
        baseLevel = baseLevel + 100
    end

    table.insert(displayLevels, minLevel)

    for level = baseLevel, maxLevels, 100 do
        if level > minLevel and level <= maxLevels then
            table.insert(displayLevels, level)
        end
    end

    if maxLevels % 100 ~= 0 then
        table.insert(displayLevels, maxLevels)
    end

    local thresholdBonus = BubbleGraph.calculateBonus(func, thresholdLevel, x1, x2)
    local thresholdAligned = (thresholdLevel % step) == 0
    local pointsWithThreshold = {}

    for level = minLevel - (minLevel % step), maxLevels, step do
        if level >= minLevel then
            local actualLevel = level
            local rawBonus = BubbleGraph.calculateBonus(func, actualLevel, x1, x2)
            local utilityRatio = calculateUtility(actualLevel, x1, x2, func) / 100

            local displayBonus =
                actualLevel <= thresholdLevel and rawBonus or thresholdBonus * (thresholdLevel / actualLevel)

            local point = {
                level = actualLevel,
                bonus = displayBonus,
                realBonus = rawBonus,
                formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
                showLabel = false,
                isThreshold = abs(actualLevel - thresholdLevel) < step / 2,
                utilityRatio = utilityRatio,
                efficiency = calculateUtility(actualLevel, x1, x2, func)
            }

            for _, displayLevel in ipairs(displayLevels) do
                if actualLevel == displayLevel then
                    point.showLabel = true
                    break
                end
            end

            table.insert(pointsWithThreshold, point)
            maxBonus = max(maxBonus, displayBonus)

            if not thresholdAligned and actualLevel < thresholdLevel and (actualLevel + step) > thresholdLevel then
                local thresholdPoint = {
                    level = thresholdLevel,
                    bonus = thresholdBonus,
                    realBonus = thresholdBonus,
                    formattedBonus = roundFormat(round(thresholdBonus, CONSTANTS.DECIMAL_PLACES)),
                    showLabel = true,
                    isThreshold = true,
                    utilityRatio = calculateUtility(thresholdLevel, x1, x2, func) / 100,
                    efficiency = calculateUtility(thresholdLevel, x1, x2, func)
                }
                table.insert(pointsWithThreshold, thresholdPoint)
                maxBonus = max(maxBonus, thresholdBonus)
            end
        end
    end

    table.sort(
        pointsWithThreshold,
        function(a, b)
            return a.level < b.level
        end
    )

    if options.displayMode then
        if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
            local baseValue = pointsWithThreshold[1] and pointsWithThreshold[1].bonus or 1
            pointsWithThreshold = calculateRelativeValues(pointsWithThreshold, baseValue)
            maxBonus = 1
        elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
            pointsWithThreshold = calculateCumulativeValues(pointsWithThreshold)
            maxBonus = pointsWithThreshold[#pointsWithThreshold].bonus
        elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
            pointsWithThreshold = calculateNormalizedValues(pointsWithThreshold)
            maxBonus = 1
        end
    end

    for i = 1, #pointsWithThreshold - 1 do
        local currentBonus = pointsWithThreshold[i].realBonus
        local nextBonus = pointsWithThreshold[i + 1].realBonus
        pointsWithThreshold[i].bonusGain = nextBonus - currentBonus
    end

    if #pointsWithThreshold > 1 then
        local lastIndex = #pointsWithThreshold
        local prevIndex = lastIndex - 1
        local lastBonusGain = pointsWithThreshold[lastIndex].realBonus - pointsWithThreshold[prevIndex].realBonus
        pointsWithThreshold[lastIndex].bonusGain = lastBonusGain
    end

    local maxDelta = 0
    for _, point in ipairs(pointsWithThreshold) do
        maxDelta = max(maxDelta, abs(point.bonusGain or 0))
    end

    if options.displayMode == BubbleGraph.DISPLAY_MODES.DELTA then
        maxBonus = maxDelta
    end

    return pointsWithThreshold, maxBonus
end

local function generateGraphPointsEfficiency(x1, x2, func)
    local points = {}
    local maxBonus = 0
    local prevEfficiency = 0

    local efficiencies = {}
    for eff = 2, 98, 2 do
        table.insert(efficiencies, eff)
    end
    table.insert(efficiencies, 99)

    for _, efficiency in ipairs(efficiencies) do
        local level = calculateEfficiencyThresholdLevel(x1, x2, func, efficiency)
        local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2)

        local point = {
            level = level,
            bonus = rawBonus,
            realBonus = rawBonus,
            formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
            showLabel = true,
            isThreshold = false,
            utilityRatio = efficiency / 100,
            efficiency = efficiency,
            bonusGain = 0,
            relativeHeight = 0
        }

        table.insert(points, point)
        maxBonus = max(maxBonus, rawBonus)
        prevEfficiency = efficiency
    end

    for i = 1, #points - 1 do
        points[i].bonusGain = abs(points[i + 1].bonus - points[i].bonus)
    end
    points[#points].bonusGain = points[#points - 1].bonusGain

    local maxGain = 0
    local minGain = math.huge
    for _, point in ipairs(points) do
        maxGain = max(maxGain, point.bonusGain)
        if point.bonusGain > 0 then
            minGain = min(minGain, point.bonusGain)
        end
    end

    if maxGain > 0 then
        for _, point in ipairs(points) do
            local normalizedGain = (point.bonusGain - minGain) / (maxGain - minGain)
            point.relativeHeight = 0.1 + (0.9 * math.sqrt(normalizedGain))
        end
    else
        for _, point in ipairs(points) do
            point.relativeHeight = 0.1
        end
    end

    return points, maxBonus
end

function BubbleGraph.generateGraph(x1, x2, func, bubbleColor, bubbleName, description, options)
    local graphWidth = options.graphWidth or CONSTANTS.GRAPH_WIDTH
    local graphHeight = options.graphHeight or CONSTANTS.GRAPH_HEIGHT
    local containerHeight = options.displayMode == "efficiency" and graphHeight or (graphHeight + 50)

    local container = createGraphContainer(graphWidth, containerHeight, options)

    local scheme = bubbleColors[bubbleColor] or bubbleColors.other

    local graphTitle =
        mw.html.create("div"):css(
        {
            position = "relative",
            width = graphWidth .. "px",
            height = "20px",
            ["font-size"] = CONSTANTS.FONT_SIZES.TITLE .. "px",
            ["padding-top"] = "5px",
            ["margin-top"] = "5px"
        }
    ):wikitext(bubbleName)

    local graphContainer =
        mw.html.create("div"):css(
        {
            position = "relative",
            width = graphWidth .. "px",
            height = graphHeight .. "px",
            ["margin-top"] = "10px",
            ["padding-top"] = "10px",
            ["z-index"] = CONSTANTS.Z_INDEX.GRAPH
        }
    )

    if options.showGrid then
        graphContainer:node(
            BubbleGraph.renderGrid(graphWidth, graphHeight, options.majorGridLines or CONSTANTS.MAJOR_GRID_LINES)
        )
    end

    local graphBody =
        mw.html.create("div"):css(
        {
            width = graphWidth .. "px",
            height = graphHeight .. "px",
            display = "flex",
            ["align-items"] = "flex-end",
            position = "absolute",
            top = "0",
            left = "0",
            ["z-index"] = CONSTANTS.Z_INDEX.GRAPH,
            ["background-image"] = format("linear-gradient(%s, %s)", scheme.backgroundStart, scheme.backgroundEnd),
            border = format("1px solid %s", CONSTANTS.COLORS.COLUMN_BORDER),
            ["border-radius"] = "3px",
            padding = "3px"
        }
    )

    local points, maxBonus
    if options.displayMode == "efficiency" then
        points, maxBonus = generateGraphPointsEfficiency(x1, x2, func, options)
    else
        points, maxBonus = generateGraphPoints(x1, x2, func, options.maxLevels, options.step, options)
    end

    for i, point in ipairs(points) do
        point.ratio = i / #points
        point.formattedDescription = formatDescription(description, point.realBonus)
        local column = createColumn(point, maxBonus, options, scheme, points)

        if options.showEfficiencyZones or options.thresholds then
            column = addThresholdAndEfficiencyIndicators(column, point, options.x2)
        end

        graphBody:node(column)
    end

    local yAxis =
        mw.html.create("div"):css(
        {
            position = "absolute",
            top = "0",
            left = -CONSTANTS.Y_AXIS_WIDTH .. "px",
            width = CONSTANTS.Y_AXIS_WIDTH .. "px",
            height = graphHeight .. "px",
            ["pointer-events"] = "none",
            ["z-index"] = CONSTANTS.Z_INDEX.Y_AXIS
        }
    )

    for i = 0, 10 do
        local value = round(maxBonus * (10 - i) / 10, 2)
        local displayValue
        if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
            displayValue = formatValueByMode(value, "RELATIVE")
        elseif options.displayMode == BubbleGraph.DISPLAY_MODES.CUMULATIVE then
            displayValue = formatValueByMode(value, "CUMULATIVE")
        elseif options.displayMode == BubbleGraph.DISPLAY_MODES.NORMALIZED then
            displayValue = formatValueByMode(value * 100, "NORMALIZED")
        else
            displayValue = roundFormat(round(value, CONSTANTS.DECIMAL_PLACES))
        end
        local label =
            mw.html.create("div"):css(
            {
                position = "absolute",
                top = (i * 10) .. "%",
                right = "5px",
                ["text-align"] = "right",
                ["font-size"] = CONSTANTS.FONT_SIZES.Y_AXIS .. "px",
                ["line-height"] = "1",
                transform = "translateY(-50%)"
            }
        ):wikitext(roundFormat(value))
        yAxis:node(label)
    end

    graphContainer:node(graphBody)
    graphContainer:node(yAxis)
    container:node(graphTitle)
    container:node(graphContainer)

    return tostring(container)
end

function BubbleGraph.render(frame)
    local args = frame.args or frame:getParent().args
    local x1 = tonumber(args.x1)
    local x2 = tonumber(args.x2)
    local func = args.func
    local options = {
        maxLevels = tonumber(args.max_levels) or CONSTANTS.MAX_LEVELS,
        minLevels = tonumber(args.min_levels) or 1,
        step = tonumber(args.step) or CONSTANTS.STEP,
        graphWidth = tonumber(args.width) or CONSTANTS.GRAPH_WIDTH,
        graphHeight = tonumber(args.height) or CONSTANTS.GRAPH_HEIGHT,
        showGrid = args.show_grid == nil and CONSTANTS.SHOW_GRID or (args.show_grid == "true"),
        majorGridLines = tonumber(args.major_grid_lines) or CONSTANTS.MAJOR_GRID_LINES,
        displayMode = args.display_mode or BubbleGraph.DISPLAY_MODES.STANDARD,
        showEfficiencyZones = args.show_efficiency_zones ~= "false",
        showAnalysis = args.show_analysis ~= "false",
        showMetrics = args.show_metrics ~= "false",
        showGrowthIndicators = args.show_growth_indicators ~= "false"
    }

    local points, maxBonus
    if options.displayMode == "efficiency" then
        points, maxBonus = generateGraphPointsEfficiency(x1, x2, func, options)
    else
        points, maxBonus = generateGraphPoints(x1, x2, func, options.maxLevels, options.step, options)
    end
    
    options.x2 = x2

    return BubbleGraph.generateGraph(
        tonumber(args.x1),
        tonumber(args.x2),
        args.func,
        sanitizeInput(args.bubble_color or args.color or "other"),
        sanitizeInput(args.bubble_name or args.name or ""),
        sanitizeInput(args.description or ""),
        options
    )
end

return BubbleGraph