Module:BubbleGraph

From IdleOn MMO Wiki

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