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("[<>&\"']", {
["<"] = "<",
[">"] = ">",
["&"] = "&",
['"'] = """,
["'"] = "'",
})
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