Module:BubbleGraph: Difference between revisions

From IdleOn MMO Wiki
m (Reverted edits by BHY4A (talk) to last revision by Butt Toast)
Tag: Rollback
mNo edit summary
 
(24 intermediate revisions by 2 users not shown)
Line 1: Line 1:
local BubbleGraph = {}
local BubbleGraph = {}


local DEFAULTS = {
local floor = math.floor
    MAX_LEVELS = 1000,
local format = string.format
    STEP = 20,
local max = math.max
    GRAPH_WIDTH = 1000,
local min = math.min
    GRAPH_HEIGHT = 250,
local abs = math.abs
    DECIMAL_PLACES = 3,
 
    FONT_FAMILY = "Idleon",
local function formatLargeNumber(num, digits)
    COLUMN_MIN_WIDTH = 2,
local si = {
    MAJOR_GRID_LINES = 10,
{ value = 1, symbol = "" },
    SHOW_GRID = true
{ value = 1E3, symbol = "k" },
}
{ value = 1E6, symbol = "M" },
{ value = 1E9, symbol = "B" },
{ value = 1E12, symbol = "T" },
{ value = 1E15, symbol = "Q" },
{ value = 1E18, symbol = "QQ" },
{ value = 1E21, symbol = "QT" },
}
 
for i = #si, 1, -1 do
if num >= si[i].value then
return format("%.2f%s", num / si[i].value, si[i].symbol)
end
end
return tostring(num)
end


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


local function calculate_threshold_level(x2)
local function lavaLog(num)
    return x2 * 90 / (100 - 90)
return math.log(max(num, 1)) / 2.303
end
end


local bubble_colors = {
BubbleGraph.bonusCalculations = {
    orange = {
add = function(level, x1, x2)
        column_start = "#ffe787ff",
if not x2 then
        column_end = "#690e0eff",
return 0
        background_start = "#ffffffcc",
end
        background_end = "#ef7500cc",
return x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1
        image = "OrangeBubble"
end,
    },
decay = function(level, x1, x2)
    green = {
if not x2 then
        column_start = "#bfffabff",
return 0
        column_end = "#09591aff",
end
        background_start = "#f7f7ffcc",
return (level * x1) / (level + x2)
        background_end = "#3fe855cc",
end,
        image = "GreenBubble"
intervalAdd = function(level, x1, x2)
    },
if not x2 then
    purple = {
return 0
        column_start = "#fcc1ffff",
end
        column_end = "#350b6aff",
return x1 + floor(level / x2)
        background_start = "#fffdfacc",
end,
        background_end = "#ca51eecc",
decayMulti = function(level, x1, x2)
        image = "PurpleBubble"
if not x2 then
    },
return 0
    yellow = {
end
        column_start = "#f7ffbdff",
return 1 + (level * x1) / (level + x2)
        column_end = "#714200ff",
end,
        background_start = "#f7fffacc",
bigBase = function(level, x1, x2)
        background_end = "#ecc200cc",
if not x2 then
        image = "YellowBubble"
return 0
    },
end
    other = {
return x1 + x2 * level
        column_start = "#a94bc1ff",
end,
        column_end = "#7e38aaff",
addLower = function(level, x1, x2)
        background_start = "#ffcffacc",
if not x2 then
        background_end = "#ee75ffcc"
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 max(floor((level + 1) / x2), 0) - max(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,
goldFood = function(level, x1, x2)
if not x1 or not x2 then
return 0
end
local amount = x1
local stack = level
local logValue = lavaLog(1 + stack)
return round(amount * 0.05 * logValue * (1 + logValue / 2.14), 2)
end,
statue = function(level, x1, x2)
if not x1 then
return 0
end
return level * x1
end,
}
}


BubbleGraph.page_color_mapping = {
local function calculateRequiredStatues(level)
    patterns = {
return floor(math.pow(level, 1.17) * math.pow(1.35, level / 10) + 1)
        ["Alchemy/Power_Cauldron/Chart"] = "orange",
end
        ["Alchemy/Quicc_Cauldron/Chart"] = "green",
 
        ["Alchemy/High%-Iq_Cauldron/Chart"] = "purple",
BubbleGraph.DISPLAY_MODES = {
        ["Alchemy/Kazam_Cauldron/Chart"] = "yellow"
STANDARD = "standard",
    },
EFFICIENCY = "efficiency",
    getColor = function(page_name)
LOGARITHMIC = "logarithmic",
        for pattern, color in pairs(BubbleGraph.page_color_mapping.patterns) do
PERCENTAGE = "percentage",
            if string.match(page_name, pattern) then
DELTA = "delta",
                return color
RELATIVE = "relative",
            end
CUMULATIVE = "cumulative",
        end
NORMALIZED = "normalized",
        return "other"
GOLDENFOOD = "goldenfood",
    end
STATUE = "statue",
}
}


function BubbleGraph._render_grid(container, width, height, major_lines)
local function roundFormat(num)
    local grid =
local str = tostring(num)
        mw.html.create("div"):addClass("bubble-graph-grid"):css(
return str:find("%.") and str:gsub("0+$", ""):gsub("%.$", "") or str
        {
end
            position = "absolute",
            top = "0",
            left = "0",
            width = width .. "px",
            height = height .. "px",
            ["pointer-events"] = "none",
            ["z-index"] = "1",
            ["background-image"] = string.format(
                "linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px), " ..
                    "linear-gradient(to bottom, rgba(0,0,0,0.1) 1px, transparent 1px)",
                width / major_lines,
                height / major_lines
            ),
            ["background-size"] = string.format("%dpx 100%%, 100%% %dpx", width / major_lines, height / major_lines)
        }
    )


    for i = 0, major_lines do
local CONSTANTS = {
        local position = (i / major_lines) * 100
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,
},
}


        grid:tag("div"):css(
CONSTANTS.FORMATS = {
            {
RELATIVE = {
                position = "absolute",
PREFIX = "",
                left = position .. "%",
SUFFIX = "x",
                top = "0",
},
                height = "100%",
CUMULATIVE = {
                width = "1px",
PREFIX = "Σ ",
                ["background-color"] = "rgba(0,0,0,0.2)"
SUFFIX = "",
            }
},
        )
NORMALIZED = {
PREFIX = "",
SUFFIX = "%",
},
}


        grid:tag("div"):css(
local function formatValueByMode(value, displayMode)
            {
local format = CONSTANTS.FORMATS[displayMode] or { PREFIX = "", SUFFIX = "" }
                position = "absolute",
local formattedValue = roundFormat(round(value, CONSTANTS.DECIMAL_PLACES))
                top = position .. "%",
return format.PREFIX .. formattedValue .. format.SUFFIX
                left = "0",
end
                width = "100%",
 
                height = "1px",
local bubbleColors = {
                ["background-color"] = "rgba(0,0,0,0.2)"
orange = {
            }
columnStart = "#ffb74dff",
        )
columnEnd = "#d84315ff",
    end
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",
},
statue = {
columnStart = "#aba28eff",
columnEnd = "#635d53ff",
backgroundStart = "#f5f2ebcc",
backgroundEnd = "#8c857acc",
},
goldFood = {
columnStart = "#ffd700ff",
columnEnd = "#b8860bff",
backgroundStart = "#fff7e6cc",
backgroundEnd = "#daa520cc",
},
other = {
columnStart = "#ab47bcff",
columnEnd = "#6a1b9aff",
backgroundStart = "#f3e5f5cc",
backgroundEnd = "#8e24aacc",
},
}


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


function BubbleGraph._interpolate_color(color1, color2, ratio)
local function getGoldFoodThreshold(x1)
    local function hex_to_rgb(hex)
local targetEfficiency = 0.90
        local r = tonumber(hex:sub(2, 3), 16)
local baseValue = x1 * 0.05
        local g = tonumber(hex:sub(4, 5), 16)
 
        local b = tonumber(hex:sub(6, 7), 16)
local low = 1
        local a = tonumber(hex:sub(8, 9) or "ff", 16)
local high = 100000
        return {r, g, b, a}
while high - low > 1 do
    end
local mid = floor((low + high) / 2)
local logValue = lavaLog(1 + mid)
local currentBonus = baseValue * logValue * (1 + logValue / 2.14)
local maxBonus = baseValue * logValue * (1 + logValue / 2.14)


    local rgb1, rgb2 = hex_to_rgb(color1), hex_to_rgb(color2)
if currentBonus / maxBonus >= targetEfficiency then
    local interpolated_rgb = {}
high = mid
else
low = mid
end
end


    for i = 1, 4 do
return high
        interpolated_rgb[i] = math.floor(rgb1[i] + ratio * (rgb2[i] - rgb1[i]))
end
    end


    local result =
local function getThresholdLevel(x2)
        string.format(
if not x2 then
        "#%02x%02x%02x%02x",
return 0
        interpolated_rgb[1],
end
        interpolated_rgb[2],
if func == "statue" then
        interpolated_rgb[3],
return CONSTANTS.MAX_LEVELS
        interpolated_rgb[4]
end
    )
if func == "goldFood" then
return getGoldFoodThreshold(x1)
end
return x2 ~= 0 and x2 * 90 / (100 - 90) or CONSTANTS.MAX_LEVELS
end


    return result
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
end


local bonus_calculations = {
local function interpolateColor(color1, color2, ratio)
    add = function(level, x1, x2)
local rgb1, rgb2 = hexToRgb(color1), hexToRgb(color2)
        if not x1 or not x2 then
local interpolatedRgb = {}
            return 0
for i = 1, 4 do
        end
interpolatedRgb[i] = floor(rgb1[i] + ratio * (rgb2[i] - rgb1[i]))
        return x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (level - 1)) / (x1 / x2)) * level * x1 or level * x1
end
    end,
return format("#%02x%02x%02x%02x", interpolatedRgb[1], interpolatedRgb[2], interpolatedRgb[3], interpolatedRgb[4])
    decay = function(level, x1, x2)
end
        if not x1 or not x2 then
            return 0
        end
        return (level * x1) / (level + x2)
    end,
    intervalAdd = function(level, x1, x2)
        if not x1 or not x2 then
            return 0
        end
        return x1 + math.floor(level / x2)
    end,
    decayMulti = function(level, x1, x2)
        if not x1 or not x2 then
            return 0
        end
        return 1 + (level * x1) / (level + x2)
    end,
    bigBase = function(level, x1, x2)
        if not x1 or not x2 then
            return 0
        end
        return x1 + x2 * level
    end,
    addLower = function(level, x1, x2)
        if not x1 or not x2 then
            return 0
        end
        return x1 + x2 * (level + 1)
    end,
    decayLower = function(level, x1, x2)
        if not x1 or 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 x1 or not x2 then
            return 0
        end
        return x1 * (level + 1) / (level + 1 + x2) - x1 * level / (level + x2)
    end,
    bigBaseLower = function(_, _, x2)
        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 x1 or not x2 then
            return 0
        end
        return x1 - x2 * level
    end,
    reduceLower = function(level, x1, x2)
        if not x1 or not x2 then
            return 0
        end
        return x1 - x2 * (level + 1)
    end
}


local function calculate_utility(level, x1, x2, func)
local function formatDescription(description, realBonus)
    local actual_max_level = max_levels or 1000
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


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


    if func == "decayMulti" then
local function calculateUtility(level, x1, x2, func)
        local current = 1 + (level * x1) / (level + x2)
local actualMaxLevel = CONSTANTS.MAX_LEVELS
        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 max_value =
            x2 ~= 0 and (((x1 + x2) / x2 + 0.5 * (actual_max_level - 1)) / (x1 / x2)) * actual_max_level * x1 or
            actual_max_level * x1
        return (current / max_value) * 100
    elseif func == "intervalAdd" then
        local current = x1 + math.floor(level / x2)
        local max_intervals = math.floor(1000 / x2)
        local max_value = x1 + max_intervals
        return (current / max_value) * 100
    elseif func == "bigBase" then
        local current = x1 + x2 * level
        local max_value = x1 + x2 * 1000
        return (current / max_value) * 100
    elseif func == "addLower" then
        local current = x1 + x2 * (level + 1)
        local max_value = x1 + x2 * (1000 + 1)
        return (current / max_value) * 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 = math.max(math.floor((level + 1) / x2), 0) - math.max(math.floor(level / x2), 0)
        local max_intervals = math.max(math.floor(1001 / x2), 0) - math.max(math.floor(1000 / x2), 0)
        return current > 0 and 100 or 0
    elseif func == "reduce" then
        local current = x1 - x2 * level
        local min_value = x1 - x2 * 1000
        local max_value = x1
        return 100 - ((current - min_value) / (max_value - min_value)) * 100
    elseif func == "reduceLower" then
        local current = x1 - x2 * (level + 1)
        local min_value = x1 - x2 * 1001
        local max_value = x1 - x2
        return 100 - ((current - min_value) / (max_value - min_value)) * 100
    end


    return 0
if not x1 or not x2 then
return 0
end
if func == "statue" then
return 100
end
if func == "goldFood" then
local currentBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
local theoreticalMax = BubbleGraph.calculateBonus(func, 100000, x1, x2)
return (currentBonus / theoreticalMax) * 100
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
end


function BubbleGraph.calculate_bonus(func, level, x1, x2)
local function calculateEfficiencyThresholdLevel(x1, x2, func, targetEfficiency)
    local calcFunc = bonus_calculations[func] or function()
local minLevel = 1
            return 0
local maxLevel = 100000
        end
local tolerance = 0.0001
    local result = round(calcFunc(level, x1, x2), DEFAULTS.DECIMAL_PLACES)
if not x2 then
    return result
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
end


local function round_format(num)
local function addThresholdAndEfficiencyIndicators(column, point, x2)
    if not num then
if not column or not point or not point.bonus then
        return "0"
return column
    end
end
    local str = tostring(num)
 
    return str:find("%.") and str:gsub("0+$", ""):gsub("%.$", "") or str
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.25",
})
elseif point.efficiency > 75 then
column:tag("div"):addClass("efficiency-zone-medium"):css({
height = "100%",
opacity = "0.25",
})
else
column:tag("div"):addClass("efficiency-zone-low"):css({
height = "100%",
opacity = "0.25",
})
end
end
 
return column
end
end


local function calculate_efficiency_threshold_level(x1, x2, func, target_efficiency)
local function calculateGrowthAnalysis(points)
    local min_level = 1
if not points or #points < 2 then
    local max_level = 100000
return {
    local tolerance = 0.0001
rates = {},
thresholds = {
average = 0,
significant = 0,
critical = 0,
},
}
end


    while min_level < max_level do
local rates = {}
        local level = math.floor((min_level + max_level) / 2)
local sum = 0
        local utility = calculate_utility(level, x1, x2, func)
local count = 0


        if math.abs(utility - target_efficiency) < tolerance then
for i = 2, #points do
            return level
local prevBonus = points[i - 1].bonus
        elseif utility < target_efficiency then
if prevBonus ~= 0 then
            min_level = level + 1
local rate = (points[i].bonus - prevBonus) / prevBonus
        else
table.insert(rates, rate)
            max_level = level - 1
sum = sum + rate
        end
count = count + 1
    end
end
end


    return min_level
local mean = count > 0 and sum / count or 0
 
return {
rates = rates,
thresholds = {
average = mean,
significant = mean * 1.5,
critical = mean * 2,
},
}
end
end


local function generate_graph_points(x1, x2, func, max_levels, step, options)
local function calculateAverage(points)
    local max_bonus = 0
local sum = 0
    local min_level = math.max(1, options.min_levels or 1)
for _, point in ipairs(points) do
    local display_levels = {}
sum = sum + point.bonus
    local threshold_level = math.floor(calculate_threshold_level(x2))
end
return sum / #points
end


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


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


    for level = base_level, max_levels, 100 do
local relativePoints = {}
        if level > min_level and level <= max_levels then
for _, point in ipairs(points) do
            table.insert(display_levels, level)
local relativeCopy = {}
        end
for k, v in pairs(point) do
    end
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


    if max_levels % 100 ~= 0 then
local function calculateCumulativeValues(points)
        table.insert(display_levels, max_levels)
if not points or #points == 0 then
    end
return {}
end


    local threshold_bonus = BubbleGraph.calculate_bonus(func, threshold_level, x1, x2)
local cumulativePoints = {}
    local threshold_aligned = (threshold_level % step) == 0
local sum = 0
    local points_with_threshold = {}
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 threshold_in_range = threshold_level >= min_level and threshold_level <= max_levels
local function calculateNormalizedValues(points)
if not points or #points == 0 then
return {}
end


    for level = min_level - (min_level % step), max_levels, step do
local minVal = math.huge
        if level >= min_level then
local maxVal = -math.huge
            local actual_level = level
for _, point in ipairs(points) do
            local raw_bonus = BubbleGraph.calculate_bonus(func, actual_level, x1, x2)
minVal = min(minVal, point.bonus)
maxVal = max(maxVal, point.bonus)
end


            local utility_ratio = calculate_utility(actual_level, x1, x2, func) / 100
local range = maxVal - minVal
local normalizedPoints = {}


            local display_bonus
for _, point in ipairs(points) do
            if actual_level <= threshold_level then
local normalizedCopy = {}
                display_bonus = raw_bonus
for k, v in pairs(point) do
            else
normalizedCopy[k] = v
                display_bonus = threshold_bonus * (threshold_level / actual_level)
end
            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


            local point = {
function BubbleGraph.renderGrid(width, height, majorLines)
                level = actual_level,
local grid = mw.html.create("div"):css({
                bonus = display_bonus,
position = "absolute",
                real_bonus = raw_bonus,
top = "0",
                formatted_bonus = round_format(round(raw_bonus, DEFAULTS.DECIMAL_PLACES)),
left = "0",
                show_label = false,
width = width .. "px",
                is_threshold = math.abs(actual_level - threshold_level) < step / 2,
height = height .. "px",
                utility_ratio = utility_ratio
["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 _, display_level in ipairs(display_levels) do
for i = 0, majorLines do
                if actual_level == display_level then
local position = (i / majorLines) * 100
                    point.show_label = true
                    break
                end
            end


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


            if not threshold_aligned and actual_level < threshold_level and (actual_level + step) > threshold_level then
return grid
                local threshold_point = {
end
                    level = threshold_level,
                    bonus = threshold_bonus,
                    real_bonus = threshold_bonus,
                    formatted_bonus = round_format(round(threshold_bonus, DEFAULTS.DECIMAL_PLACES)),
                    show_label = true,
                    is_threshold = true,
                    utility_ratio = calculate_utility(threshold_level, x1, x2, func) / 100
                }
                table.insert(points_with_threshold, threshold_point)
                max_bonus = math.max(max_bonus, threshold_bonus)
            end
        end
    end


    table.sort(
local function createGraphContainer(width, height, options)
        points_with_threshold,
local container = mw.html
        function(a, b)
.create("div")
            return a.level < b.level
:addClass("bubble-graph-container")
        end
: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 points_with_threshold, max_bonus, threshold_level
return container
end
end


local function format_description(description, bonus)
local function createTooltipText(point, options, allPoints)
    return description:gsub(
local leftSection = {}
        "{",
local rightSection = {}
        '<span class="bonus-value" style="color: rgba(255,0,0,1); text-shadow: -.065em 0 rgba(255,0,0,0.5), 0 .065em rgba(255,0,0,0.5), .065em 0 rgba(255,0,0,0.5), 0 -.065em rgba(255,0,0,0.5); font-size: 1.2em;">' ..
if options.showMetrics then
             bonus .. "</span>"
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.STATUE then
table.insert(leftSection, format("Level: %s", tostring(point.level)))
table.insert(leftSection, format("Bonus: %s", point.formattedBonus))
table.insert(
leftSection,
format("Required Statues: %s (%s total)", point.formattedStatues, point.formattedTotalStatues)
)
else
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
table.insert(leftSection, format("Amount: %s", tostring(point.level)))
else
table.insert(leftSection, format("Level: %s", tostring(point.level)))
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
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%s", sign, tostring(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
end


local function generate_graph_points_efficiency(x1, x2, func, options)
local function createColumn(point, maxBonus, options, scheme, points)
    local points = {}
local height
    local max_bonus = 0
local displayValue
    local prev_efficiency = 0


    local efficiencies = {}
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
    for eff = 2, 98, 2 do
if point.bonus > 0 and maxBonus > 0 then
        table.insert(efficiencies, eff)
local scale = math.sqrt(point.bonus) / math.sqrt(maxBonus)
    end
height = floor(scale * options.graphHeight)
    table.insert(efficiencies, 99)
height = min(height, options.graphHeight)
height = max(height, 1)
else
height = 0
end
displayValue = point.formattedBonus
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.LOGARITHMIC then
if point.bonus > 0 and maxBonus > 0 then
local scale = (point.bonus ^ (1 / 3)) / (maxBonus ^ (1 / 3))
height = floor(scale * options.graphHeight)
height = min(height, options.graphHeight)
height = max(height, 1)
else
height = 0
end
displayValue = point.formattedBonus
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
if point.bonus > 0 and maxBonus > 0 then
local minValue = math.log(1)
local maxValue = math.log(maxBonus + 1)
local currentValue = math.log(point.bonus + 1)
height = floor(((currentValue - minValue) / (maxValue - minValue)) * options.graphHeight)
height = min(height, options.graphHeight)
height = max(height, 1)
else
height = 0
end
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
local relativeValue = point.bonus / maxBonus
local scale = 0.1
height = min(floor(relativeValue * options.graphHeight * scale), options.graphHeight)
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


    for _, efficiency in ipairs(efficiencies) do
if not displayValue then
        local level = calculate_efficiency_threshold_level(x1, x2, func, efficiency)
if options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
        local raw_bonus = BubbleGraph.calculate_bonus(func, level, x1, x2)
displayValue = formatValueByMode(point.bonus, "RELATIVE")
        local efficiency_gain = efficiency - prev_efficiency
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


        local point = {
height = floor(height)
            level = level,
            bonus = raw_bonus,
            real_bonus = raw_bonus,
            formatted_bonus = round_format(round(raw_bonus, DEFAULTS.DECIMAL_PLACES)),
            show_label = true,
            is_threshold = false,
            utility_ratio = efficiency / 100,
            efficiency = efficiency,
            efficiency_gain = efficiency_gain
        }


        table.insert(points, point)
local column = mw.html
        max_bonus = math.max(max_bonus, raw_bonus)
.create("div")
        prev_efficiency = efficiency
:addClass("bubble-graph-column")
    end
: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 prev_bonus = 0
local color
    for i, point in ipairs(points) do
if options.displayMode == "efficiency" then
        local bonus_gain = point.bonus - prev_bonus
local ratio = point.relativeHeight
        point.bonus_gain = bonus_gain
color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio)
        prev_bonus = point.bonus
else
    end
local thresholdLevel = getThresholdLevel(options.x2, options.func, options.x1)
local maxHeight = options.graphHeight
local heightRatio = height / maxHeight


    local max_gain = 0
if point.level <= thresholdLevel then
    for _, point in ipairs(points) do
local ratio = point.level / thresholdLevel
        max_gain = math.max(max_gain, point.bonus_gain)
color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio)
    end
else
local ratio = (point.level - thresholdLevel) / (options.maxLevels - thresholdLevel)
color = interpolateColor(scheme.columnEnd, scheme.columnStart, ratio)
end
end
column:css("background-color", color)


    for _, point in ipairs(points) do
local tooltipText = createTooltipText(point, options, points)
        point.relative_height = point.bonus_gain / max_gain
    end


    return points, max_bonus
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",
["z-index"] = 1,
})
: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
if options.displayMode == "efficiency" then
labelText = tostring(point.efficiency) .. "%"
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
labelText = formatLargeNumber(point.level)
local formattedNumber = formatLargeNumber(point.level, 2)
labelText = roundFormat(tonumber(formattedNumber:match("[%d.]+")) or point.level) .. formattedNumber:match("[^%d.]*$")
else
labelText = tostring(point.level)
end
 
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
end


function BubbleGraph.generate_graph(x1, x2, func, bubble_color, bubble_name, bubble_number, description, options)
local function generateGraphPoints(x1, x2, func, maxLevels, step, options)
    assert(type(x1) == "number", "x1 must be a number")
local maxBonus = 0
    assert(type(x2) == "number", "x2 must be a number")
local minLevel = max(1, options.minLevels or 1)
    assert(type(func) == "string", "func must be a string")
local displayLevels = {}
local thresholdLevel = floor(getThresholdLevel(x2, func, x1))
local pointsWithThreshold = {}
 
if options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then
local lastStatues = 0
 
for level = 10, 500, 10 do
local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
local totalStatues = calculateRequiredStatues(level)
local statuesForLevel = totalStatues - lastStatues


    options = options or {}
local point = {
    local graph_width = options.graph_width or DEFAULTS.GRAPH_WIDTH
level = level,
    local graph_height = options.graph_height or DEFAULTS.GRAPH_HEIGHT
bonus = rawBonus,
realBonus = rawBonus,
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
showLabel = true,
isThreshold = false,
statues = statuesForLevel,
totalStatues = totalStatues,
formattedStatues = formatLargeNumber(statuesForLevel, 2),
formattedTotalStatues = formatLargeNumber(totalStatues, 2),
}


    local points, max_bonus
table.insert(pointsWithThreshold, point)
    if options.display_mode == "efficiency" then
maxBonus = max(maxBonus, rawBonus)
        points, max_bonus = generate_graph_points_efficiency(x1, x2, func, options)
lastStatues = totalStatues
    else
end
        points, max_bonus =
            generate_graph_points(
            x1,
            x2,
            func,
            options.max_levels or DEFAULTS.MAX_LEVELS,
            options.step or DEFAULTS.STEP,
            options
        )
    end


    local container_height = options.display_mode == "efficiency" and graph_height or (graph_height + 50)
return pointsWithThreshold, maxBonus
end


    local container =
local baseLevel = minLevel - (minLevel % 100)
        mw.html.create("div"):addClass("bubble-graph-container"):attr("data-x1", x1):attr("data-x2", x2):attr(
if minLevel % 100 ~= 0 then
        "data-func",
baseLevel = baseLevel + 100
        func
end
    ):attr("data-color", bubble_color):attr("data-name", bubble_name):attr("data-number", bubble_number):attr(
        "data-description",
        description
    ):css(
        {
            width = (graph_width) .. "px",
            height = container_height .. "px",
            position = "relative",
            ["font-family"] = DEFAULTS.FONT_FAMILY,
            ["text-align"] = "center",
            ["margin-bottom"] = "50px",
            ["padding-left"] = "40px"
        }
    )


    local scheme = bubble_colors[bubble_color] or bubble_colors.other
table.insert(displayLevels, minLevel)
    local bubble_image_name = scheme.image and (scheme.image .. bubble_number) or ""


    local graph_container =
for level = baseLevel, maxLevels, 100 do
        mw.html.create("div"):css(
if level > minLevel and level <= maxLevels then
        {
table.insert(displayLevels, level)
            position = "relative",
end
            width = graph_width .. "px",
end
            height = graph_height .. "px",
            ["margin-top"] = "60px",
            ["z-index"] = "1"
        }
    )


    if options.show_grid then
if maxLevels % 100 ~= 0 then
        graph_container:node(
table.insert(displayLevels, maxLevels)
            BubbleGraph._render_grid(
end
                graph_container,
                graph_width,
                graph_height,
                options.major_grid_lines or DEFAULTS.MAJOR_GRID_LINES
            )
        )
    end


    local graph_body =
local thresholdBonus = BubbleGraph.calculateBonus(func, thresholdLevel, x1, x2)
        mw.html.create("div"):css(
local thresholdAligned = (thresholdLevel % step) == 0
        {
local pointsWithThreshold = {}
            width = graph_width .. "px",
            height = graph_height .. "px",
            display = "flex",
            ["align-items"] = "flex-end",
            position = "absolute",
            top = "0",
            left = "0",
            ["z-index"] = "2",
            ["background-image"] = string.format(
                "linear-gradient(%s, %s)",
                scheme.background_start,
                scheme.background_end
            ),
            border = "1px solid #000",
            ["border-radius"] = "3px",
            padding = "3px"
        }
    )


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


        if options.display_mode == "efficiency" then
local displayBonus = actualLevel <= thresholdLevel and rawBonus
            height = math.floor(point.relative_height * 5 * graph_height)
or thresholdBonus * (thresholdLevel / actualLevel)
        else
            height = math.floor((point.bonus / max_bonus) * graph_height)
        end


        local ratio = i / #points
local point = {
        local color
level = actualLevel,
        if options.display_mode == "efficiency" then
bonus = displayBonus,
            local ratio = point.efficiency / 99
realBonus = rawBonus,
            color = BubbleGraph._interpolate_color(scheme.column_start, scheme.column_end, ratio)
formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
        else
showLabel = false,
            local threshold_level = math.floor(calculate_threshold_level(x2))
isThreshold = actualLevel == thresholdLevel,
            if point.level <= threshold_level then
utilityRatio = utilityRatio,
                local ratio = point.level / threshold_level
efficiency = calculateUtility(actualLevel, x1, x2, func),
                color = BubbleGraph._interpolate_color(scheme.column_start, scheme.column_end, ratio)
}
            else
                local ratio = (point.level - threshold_level) / (options.max_levels - threshold_level)
                color = BubbleGraph._interpolate_color(scheme.column_end, scheme.column_start, ratio)
            end
        end


        local column = mw.html.create("div"):addClass("bubble-graph-column")
for _, displayLevel in ipairs(displayLevels) do
        local column_css = {
if actualLevel == displayLevel then
            flex = "1",
point.showLabel = true
            height = height .. "px",
break
            ["background-color"] = color,
end
            position = "relative",
end
            ["min-width"] = DEFAULTS.COLUMN_MIN_WIDTH .. "px"
        }


        column:css(column_css)
table.insert(pointsWithThreshold, point)
maxBonus = max(maxBonus, displayBonus)


        local formatted_description = format_description(description, point.formatted_bonus)
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


        local tooltip_text
table.sort(pointsWithThreshold, function(a, b)
        if options.display_mode == "efficiency" then
return a.level < b.level
            tooltip_text =
end)
                string.format(
                "Level: %s<br>Efficiency: %d%%<br><span class='description'>%s</span>",
                tostring(point.level),
                point.efficiency or 0,
                formatted_description
            )
        else
            tooltip_text =
                string.format(
                "Level: %s<br>Efficiency: %.2f%%<br><span class='description'>%s</span>",
                tostring(point.level),
                (point.utility_ratio or 0) * 100,
                formatted_description
            )
        end


        local tooltip =
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
            mw.html.create("div"):addClass("bubble-graph-tooltip"):css(
maxBonus = BubbleGraph.calculateBonus(func, maxLevels, x1, x2)
            {
for _, point in ipairs(pointsWithThreshold) do
                ["background-color"] = "#000142cc",
point.maxPossibleBonus = maxBonus
                ["border"] = "1px solid #000142",
end
                ["padding"] = "5px",
end
                ["color"] = "#fff",
                ["text-align"] = "center",
                ["z-index"] = "10",
                ["width"] = "150px"
            }
        ):wikitext(tooltip_text)


        column:node(tooltip)
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


        if point.is_threshold then
for i = 1, #pointsWithThreshold - 1 do
            column:tag("div"):addClass("bubble-graph-threshold"):css(
local currentBonus = pointsWithThreshold[i].realBonus
                {
local nextBonus = pointsWithThreshold[i + 1].realBonus
                    position = "absolute",
pointsWithThreshold[i].bonusGain = nextBonus - currentBonus
                    top = "-5px",
end
                    width = "100%",
                    height = "3px",
                    ["background-color"] = "#ff0000",
                    ["z-index"] = "4"
                }
            )
        end


        if point.formatted_bonus ~= "0" then
if #pointsWithThreshold > 1 then
            if options.display_mode == "efficiency" then
local lastIndex = #pointsWithThreshold
                height = math.floor(point.relative_height * 5 * graph_height)
local prevIndex = lastIndex - 1
            else
local lastBonusGain = pointsWithThreshold[lastIndex].realBonus - pointsWithThreshold[prevIndex].realBonus
                height = math.floor((point.bonus / max_bonus) * graph_height)
pointsWithThreshold[lastIndex].bonusGain = lastBonusGain
            end
end


            if height < 20 then
local maxDelta = 0
                column:tag("span"):addClass("bubble-graph-value"):css(
for _, point in ipairs(pointsWithThreshold) do
                    {
maxDelta = max(maxDelta, abs(point.bonusGain or 0))
                        position = "absolute",
end
                        bottom = "100%",
                        left = "50%",
                        transform = "translate(-50%, -2px) rotate(-90deg)",
                        color = "#fff",
                        ["text-shadow"] = "-.065em 0 #000, 0 .065em #000, .065em 0 #000, 0 -.065em #000",
                        ["white-space"] = "nowrap"
                    }
                ):wikitext(point.formatted_bonus)
            else
                local text_y_pos = math.min(height / 2, height - 15)
                column:tag("span"):addClass("bubble-graph-value"):css(
                    {
                        position = "absolute",
                        bottom = text_y_pos .. "px",
                        left = "50%",
                        transform = "translate(-50%, 50%) rotate(-90deg)",
                        color = "#fff",
                        ["text-shadow"] = "-.065em 0 #000, 0 .065em #000, .065em 0 #000, 0 -.065em #000",
                        ["white-space"] = "nowrap"
                    }
                ):wikitext(point.formatted_bonus)
            end
        end


        if point.show_label then
if options.displayMode == BubbleGraph.DISPLAY_MODES.DELTA then
            local label_text =
maxBonus = maxDelta
                options.display_mode == "efficiency" and tostring(point.efficiency) .. "%" or tostring(point.level)
end


            column:tag("div"):addClass("bubble-graph-level"):css(
return pointsWithThreshold, maxBonus
                {
end
                    position = "absolute",
                    bottom = "-20px",
                    width = "100%",
                    ["text-align"] = "center",
                    ["font-size"] = "10px",
                    ["z-index"] = "3",
                    ["text-wrap"] = "nowrap"
                }
            ):wikitext(label_text)
        end


        graph_body:node(column)
local function generateGraphPointsEfficiency(x1, x2, func)
    end
local points = {}
local maxBonus = 0
local prevEfficiency = 0


    local y_axis =
local efficiencies = {}
        mw.html.create("div"):addClass("bubble-graph-y-axis"):css(
for eff = 2, 98, 2 do
        {
table.insert(efficiencies, eff)
            position = "absolute",
end
            top = "0",
table.insert(efficiencies, 99)
            left = "-40px",
            width = "40px",
            height = graph_height .. "px",
            ["pointer-events"] = "none",
            ["z-index"] = "3"
        }
    )


    for i = 0, 10 do
for _, efficiency in ipairs(efficiencies) do
        local value = round(max_bonus * (10 - i) / 10, 2)
local level = calculateEfficiencyThresholdLevel(x1, x2, func, efficiency)
        local label =
local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
            mw.html.create("div"):addClass("bubble-graph-y-value"):css(
            {
                position = "absolute",
                top = (i * 10) .. "%",
                right = "5px",
                ["text-align"] = "right",
                ["font-size"] = "12px",
                ["line-height"] = "1",
                transform = "translateY(-50%)"
            }
        ):wikitext(round_format(value))
        y_axis:node(label)
    end


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


    local pagination =
table.insert(points, point)
        mw.html.create("div"):addClass("bubble-graph-pagination"):css(
maxBonus = max(maxBonus, rawBonus)
        {
prevEfficiency = efficiency
            position = "absolute",
end
            bottom = "0",
            left = "0",
            width = "100%",
            height = "40px",
            display = "flex",
            ["justify-content"] = "center",
            ["align-items"] = "center",
            gap = "10px"
        }
    )


    if not options.display_mode == "efficiency" then
for i = 1, #points - 1 do
        local pagination =
points[i].bonusGain = abs(points[i + 1].bonus - points[i].bonus)
            mw.html.create("div"):addClass("bubble-graph-pagination"):css(
end
            {
points[#points].bonusGain = points[#points - 1].bonusGain
                position = "absolute",
                bottom = "0",
                left = "0",
                width = "100%",
                height = "40px",
                display = "flex",
                ["justify-content"] = "center",
                ["align-items"] = "center",
                gap = "10px"
            }
        )


        local prev_page =
local maxGain = 0
            mw.html.create("div"):addClass("bubble-graph-page-button prev-page"):css(
local minGain = math.huge
            {
for _, point in ipairs(points) do
                width = "30px",
maxGain = max(maxGain, point.bonusGain)
                height = "30px",
if point.bonusGain > 0 then
                ["background-color"] = "#eee",
minGain = min(minGain, point.bonusGain)
                ["border-radius"] = "5px",
end
                cursor = "pointer",
end
                display = "flex",
                ["justify-content"] = "center",
                ["align-items"] = "center"
            }
        ):wikitext("[[File:Bubble Graph Left.png|link=]]")


        local next_page =
if maxGain > 0 then
            mw.html.create("div"):addClass("bubble-graph-page-button next-page"):css(
for _, point in ipairs(points) do
            {
local normalizedGain = (point.bonusGain - minGain) / (maxGain - minGain)
                width = "30px",
point.relativeHeight = 0.1 + (0.9 * math.sqrt(normalizedGain))
                height = "30px",
end
                ["background-color"] = "#eee",
else
                ["border-radius"] = "5px",
for _, point in ipairs(points) do
                cursor = "pointer",
point.relativeHeight = 0.1
                display = "flex",
end
                ["justify-content"] = "center",
end
                ["align-items"] = "center"
            }
        ):wikitext("[[File:Bubble Graph Right.png|link=]]")


        local page_info =
return points, maxBonus
            mw.html.create("div"):addClass("bubble-graph-page-info"):css(
end
            {
                ["font-size"] = "14px"
            }
        ):wikitext("Levels 1-1000")


        pagination:node(prev_page)
function BubbleGraph.generateGraph(x1, x2, func, bubbleColor, bubbleName, description, options)
        pagination:node(page_info)
local graphWidth = options.graphWidth or CONSTANTS.GRAPH_WIDTH
        pagination:node(next_page)
local graphHeight = options.graphHeight or CONSTANTS.GRAPH_HEIGHT
        container:node(pagination)
local containerHeight = options.displayMode == "efficiency" and graphHeight or (graphHeight + 50)
    end
    graph_container:node(graph_body)
    graph_container:node(y_axis)
    container:node(graph_container)


    return tostring(container)
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
end


function BubbleGraph.render(frame)
function BubbleGraph.render(frame)
    local args = frame.args or frame:getParent().args
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 = 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 scheme = bubbleColors[args.bubble_color or args.color or "other"]


    local x1 = tonumber(args.x1)
if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
    local x2 = tonumber(args.x2)
options.maxLevels = 100000
    assert(x1 and x2 and args.func, "x1, x2 and func are missing")
options.step = 2000
func = "goldFood"
scheme = bubbleColors.goldFood
elseif options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then
options.maxLevels = 500
options.step = 10
options.minLevels = 10
func = "statue"
scheme = bubbleColors.statue
end


    local options = {
options.x2 = x2
        max_levels = tonumber(args.max_levels) or DEFAULTS.MAX_LEVELS,
options.func = func
        min_levels = tonumber(args.min_levels) or 1,
options.x1 = x1
        step = tonumber(args.step) or DEFAULTS.STEP,
        graph_width = tonumber(args.width) or DEFAULTS.GRAPH_WIDTH,
        graph_height = tonumber(args.height) or DEFAULTS.GRAPH_HEIGHT,
        show_grid = args.show_grid == nil and DEFAULTS.SHOW_GRID or (args.show_grid == "true" or args.show_grid == true),
        major_grid_lines = tonumber(args.major_grid_lines) or DEFAULTS.MAJOR_GRID_LINES,
        display_mode = args.display_mode or "normal"
    }


    local description = args.description or ""
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


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


return BubbleGraph
return BubbleGraph

Latest revision as of 05:20, 5 January 2025

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

local BubbleGraph = {}

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

local function formatLargeNumber(num, digits)
	local si = {
		{ value = 1, symbol = "" },
		{ value = 1E3, symbol = "k" },
		{ value = 1E6, symbol = "M" },
		{ value = 1E9, symbol = "B" },
		{ value = 1E12, symbol = "T" },
		{ value = 1E15, symbol = "Q" },
		{ value = 1E18, symbol = "QQ" },
		{ value = 1E21, symbol = "QT" },
	}

	for i = #si, 1, -1 do
		if num >= si[i].value then
			return format("%.2f%s", num / si[i].value, si[i].symbol)
		end
	end
	return tostring(num)
end

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

local function lavaLog(num)
	return math.log(max(num, 1)) / 2.303
end

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 + 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 max(floor((level + 1) / x2), 0) - max(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,
	goldFood = function(level, x1, x2)
		if not x1 or not x2 then
			return 0
		end
		local amount = x1
		local stack = level
		local logValue = lavaLog(1 + stack)
		return round(amount * 0.05 * logValue * (1 + logValue / 2.14), 2)
	end,
	statue = function(level, x1, x2)
		if not x1 then
			return 0
		end
		return level * x1
	end,
}

local function calculateRequiredStatues(level)
	return floor(math.pow(level, 1.17) * math.pow(1.35, level / 10) + 1)
end

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

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",
	},
	statue = {
		columnStart = "#aba28eff",
		columnEnd = "#635d53ff",
		backgroundStart = "#f5f2ebcc",
		backgroundEnd = "#8c857acc",
	},
	goldFood = {
		columnStart = "#ffd700ff",
		columnEnd = "#b8860bff",
		backgroundStart = "#fff7e6cc",
		backgroundEnd = "#daa520cc",
	},
	other = {
		columnStart = "#ab47bcff",
		columnEnd = "#6a1b9aff",
		backgroundStart = "#f3e5f5cc",
		backgroundEnd = "#8e24aacc",
	},
}

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

local function getGoldFoodThreshold(x1)
	local targetEfficiency = 0.90
	local baseValue = x1 * 0.05

	local low = 1
	local high = 100000
	while high - low > 1 do
		local mid = floor((low + high) / 2)
		local logValue = lavaLog(1 + mid)
		local currentBonus = baseValue * logValue * (1 + logValue / 2.14)
		local maxBonus = baseValue * logValue * (1 + logValue / 2.14)

		if currentBonus / maxBonus >= targetEfficiency then
			high = mid
		else
			low = mid
		end
	end

	return high
end

local function getThresholdLevel(x2)
	if not x2 then
		return 0
	end
	if func == "statue" then
		return CONSTANTS.MAX_LEVELS
	end
	if func == "goldFood" then
		return getGoldFoodThreshold(x1)
	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 == "statue" then
		return 100
	end
	if func == "goldFood" then
		local currentBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
		local theoreticalMax = BubbleGraph.calculateBonus(func, 100000, x1, x2)
		return (currentBonus / theoreticalMax) * 100
	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.25",
			})
		elseif point.efficiency > 75 then
			column:tag("div"):addClass("efficiency-zone-medium"):css({
				height = "100%",
				opacity = "0.25",
			})
		else
			column:tag("div"):addClass("efficiency-zone-low"):css({
				height = "100%",
				opacity = "0.25",
			})
		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 = min(minVal, point.bonus)
		maxVal = 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 = {}
	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.STATUE then
		table.insert(leftSection, format("Level: %s", tostring(point.level)))
		table.insert(leftSection, format("Bonus: %s", point.formattedBonus))
		table.insert(
			leftSection,
			format("Required Statues: %s (%s total)", point.formattedStatues, point.formattedTotalStatues)
		)
	else
		if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
			table.insert(leftSection, format("Amount: %s", tostring(point.level)))
		else
			table.insert(leftSection, format("Level: %s", tostring(point.level)))
		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
	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%s", sign, tostring(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.GOLDENFOOD then
		if point.bonus > 0 and maxBonus > 0 then
			local scale = math.sqrt(point.bonus) / math.sqrt(maxBonus)
			height = floor(scale * options.graphHeight)
			height = min(height, options.graphHeight)
			height = max(height, 1)
		else
			height = 0
		end
		displayValue = point.formattedBonus
	elseif options.displayMode == BubbleGraph.DISPLAY_MODES.LOGARITHMIC then
		if point.bonus > 0 and maxBonus > 0 then
			local scale = (point.bonus ^ (1 / 3)) / (maxBonus ^ (1 / 3))
			height = floor(scale * options.graphHeight)
			height = min(height, options.graphHeight)
			height = max(height, 1)
		else
			height = 0
		end
		displayValue = point.formattedBonus
	elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
		if point.bonus > 0 and maxBonus > 0 then
			local minValue = math.log(1)
			local maxValue = math.log(maxBonus + 1)
			local currentValue = math.log(point.bonus + 1)
			height = floor(((currentValue - minValue) / (maxValue - minValue)) * options.graphHeight)
			height = min(height, options.graphHeight)
			height = max(height, 1)
		else
			height = 0
		end
	elseif options.displayMode == BubbleGraph.DISPLAY_MODES.RELATIVE then
		local relativeValue = point.bonus / maxBonus
		local scale = 0.1
		height = min(floor(relativeValue * options.graphHeight * scale), options.graphHeight)
	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, options.func, options.x1)
		local maxHeight = options.graphHeight
		local heightRatio = height / maxHeight

		if point.level <= thresholdLevel then
			local ratio = point.level / thresholdLevel
			color = interpolateColor(scheme.columnStart, scheme.columnEnd, ratio)
		else
			local ratio = (point.level - thresholdLevel) / (options.maxLevels - thresholdLevel)
			color = interpolateColor(scheme.columnEnd, scheme.columnStart, ratio)
		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",
				["z-index"] = 1,
			})
			: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
		if options.displayMode == "efficiency" then
			labelText = tostring(point.efficiency) .. "%"
		elseif options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
			labelText = formatLargeNumber(point.level)
			local formattedNumber = formatLargeNumber(point.level, 2)
			labelText = roundFormat(tonumber(formattedNumber:match("[%d.]+")) or point.level) .. formattedNumber:match("[^%d.]*$")
		else
			labelText = tostring(point.level)
		end

		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, func, x1))
	local pointsWithThreshold = {}

	if options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then
		local lastStatues = 0

		for level = 10, 500, 10 do
			local rawBonus = BubbleGraph.calculateBonus(func, level, x1, x2)
			local totalStatues = calculateRequiredStatues(level)
			local statuesForLevel = totalStatues - lastStatues

			local point = {
				level = level,
				bonus = rawBonus,
				realBonus = rawBonus,
				formattedBonus = roundFormat(round(rawBonus, CONSTANTS.DECIMAL_PLACES)),
				showLabel = true,
				isThreshold = false,
				statues = statuesForLevel,
				totalStatues = totalStatues,
				formattedStatues = formatLargeNumber(statuesForLevel, 2),
				formattedTotalStatues = formatLargeNumber(totalStatues, 2),
			}

			table.insert(pointsWithThreshold, point)
			maxBonus = max(maxBonus, rawBonus)
			lastStatues = totalStatues
		end

		return pointsWithThreshold, maxBonus
	end

	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 = actualLevel == thresholdLevel,
				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 == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
		maxBonus = BubbleGraph.calculateBonus(func, maxLevels, x1, x2)
		for _, point in ipairs(pointsWithThreshold) do
			point.maxPossibleBonus = maxBonus
		end
	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 = 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 scheme = bubbleColors[args.bubble_color or args.color or "other"]

	if options.displayMode == BubbleGraph.DISPLAY_MODES.GOLDENFOOD then
		options.maxLevels = 100000
		options.step = 2000
		func = "goldFood"
		scheme = bubbleColors.goldFood
	elseif options.displayMode == BubbleGraph.DISPLAY_MODES.STATUE then
		options.maxLevels = 500
		options.step = 10
		options.minLevels = 10
		func = "statue"
		scheme = bubbleColors.statue
	end

	options.x2 = x2
	options.func = func
	options.x1 = x1

	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

	return BubbleGraph.generateGraph(
		tonumber(args.x1),
		tonumber(args.x2),
		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