-- ------------------------------------------------------------------------------ --
--                                TradeSkillMaster                                --
--                          https://tradeskillmaster.com                          --
--    All Rights Reserved - Detailed license information included with addon.     --
-- ------------------------------------------------------------------------------ --

--- Graph UI Element Class.
-- The graph element allows for generating line graphs. It is a subclass of the @{Element} class.
-- @classmod Graph

local _, TSM = ...
local Math = TSM.Include("Util.Math")
local Theme = TSM.Include("Util.Theme")
local ScriptWrapper = TSM.Include("Util.ScriptWrapper")
local Graph = TSM.Include("LibTSMClass").DefineClass("Graph", TSM.UI.Element)
local UIElements = TSM.Include("UI.UIElements")
UIElements.Register(Graph)
TSM.UI.Graph = Graph
local private = {}
local PLOT_X_LABEL_WIDTH = 48
local PLOT_X_LABEL_HEIGHT = 16
local PLOT_X_LABEL_MARGIN = 6
local PLOT_Y_LABEL_WIDTH = 48
local PLOT_Y_LABEL_HEIGHT = 16
local PLOT_Y_LABEL_MARGIN = 4
local PLOT_HIGHLIGHT_TEXT_WIDTH = 80
local PLOT_HIGHLIGHT_TEXT_HEIGHT = 16
local PLOT_X_EXTRA_HIT_RECT = 4
local PLOT_Y_MARGIN = 4
local LINE_THICKNESS = 1
local LINE_THICKNESS_RATIO = 16
local PLOT_MIN_X_LINE_SPACING = PLOT_X_LABEL_WIDTH * 1.5 + 8
local PLOT_MIN_Y_LINE_SPACING = PLOT_Y_LABEL_HEIGHT * 1.5 + 8
local HOVER_LINE_THICKNESS = 1
local MAX_FILL_ALPHA = 0.5
local SELECTION_ALPHA = 0.2
local MAX_PLOT_POINTS = 300



-- ============================================================================
-- Public Class Methods
-- ============================================================================

function Graph.__init(self)
	local frame = UIElements.CreateFrame(self, "Frame", nil, nil, TSM.IsShadowlands() and "BackdropTemplate" or nil)

	self.__super:__init(frame)

	frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })

	frame.plot = CreateFrame("Frame", nil, frame, nil)
	frame.plot:SetPoint("BOTTOMLEFT", PLOT_Y_LABEL_WIDTH, PLOT_X_LABEL_HEIGHT)
	frame.plot:SetPoint("TOPRIGHT", -PLOT_X_EXTRA_HIT_RECT, -PLOT_HIGHLIGHT_TEXT_HEIGHT - PLOT_Y_MARGIN)
	frame.plot:SetHitRectInsets(-PLOT_X_EXTRA_HIT_RECT, -PLOT_X_EXTRA_HIT_RECT, 0, 0)
	frame.plot:EnableMouse(true)
	ScriptWrapper.Set(frame.plot, "OnEnter", private.PlotFrameOnEnter, self)
	ScriptWrapper.Set(frame.plot, "OnLeave", private.PlotFrameOnLeave, self)
	ScriptWrapper.Set(frame.plot, "OnMouseDown", private.PlotFrameOnMouseDown, self)
	ScriptWrapper.Set(frame.plot, "OnMouseUp", private.PlotFrameOnMouseUp, self)

	frame.plot.dot = frame.plot:CreateTexture(nil, "ARTWORK", nil, 3)
	TSM.UI.TexturePacks.SetTextureAndSize(frame.plot.dot, "uiFrames.HighlightDot")

	frame.plot.hoverLine = frame.plot:CreateTexture(nil, "ARTWORK", nil, 2)
	frame.plot.hoverLine:SetWidth(HOVER_LINE_THICKNESS)
	frame.plot.hoverLine:Hide()

	frame.plot.hoverText = frame.plot:CreateFontString()
	frame.plot.hoverText:SetSize(PLOT_HIGHLIGHT_TEXT_WIDTH, PLOT_HIGHLIGHT_TEXT_HEIGHT)
	frame.plot.hoverText:Hide()

	frame.plot.selectionBox = frame.plot:CreateTexture(nil, "ARTWORK", nil, 2)
	frame.plot.selectionBox:Hide()

	self._usedTextures = {}
	self._freeTextures = {}
	self._usedFontStrings = {}
	self._freeFontStrings = {}
	self._xValuesFiltered = {}
	self._yLookup = {}
	self._yValueFunc = nil
	self._xFormatFunc = nil
	self._yFormatFunc = nil
	self._xStepFunc = nil
	self._yStepFunc = nil
	self._xMin = nil
	self._xMax = nil
	self._yMin = nil
	self._yMax = nil
	self._isMouseOver = false
	self._selectionStartX = nil
	self._zoomStart = nil
	self._zoomEnd = nil
	self._onZoomChanged = nil
	self._onHoverUpdate = nil
end

function Graph.Release(self)
	self:_ReleaseAllTextures()
	self:_ReleaseAllFontStrings()
	wipe(self._xValuesFiltered)
	wipe(self._yLookup)
	self._yValueFunc = nil
	self._xFormatFunc = nil
	self._yFormatFunc = nil
	self._xStepFunc = nil
	self._yStepFunc = nil
	self._xMin = nil
	self._xMax = nil
	self._yMin = nil
	self._yMax = nil
	self._isMouseOver = false
	self._selectionStartX = nil
	self._zoomStart = nil
	self._zoomEnd = nil
	self._onZoomChanged = nil
	self._onHoverUpdate = nil
	self.__super:Release()
end

--- Sets the step size of the axes.
-- @tparam Graph self The graph object
-- @tparam function x A function which gets the next x-axis step value
-- @tparam function y A function which gets the next y-axis step value
-- @treturn Graph The graph object
function Graph.SetAxisStepFunctions(self, x, y)
	self._xStepFunc = x
	self._yStepFunc = y
	return self
end

function Graph.SetXRange(self, xMin, xMax, stepInterval)
	assert(xMin <= xMax)
	self._xMin = xMin
	self._xMax = xMax
	self._xStepInterval = stepInterval
	self._zoomStart = xMin
	self._zoomEnd = xMax
	return self
end

function Graph.SetZoom(self, zoomStart, zoomEnd)
	self._zoomStart = zoomStart
	self._zoomEnd = zoomEnd
	return self
end

function Graph.GetZoom(self)
	return self._zoomStart, self._zoomEnd
end

function Graph.GetXRange(self)
	local yMin, yMax = nil, nil
	for _, x in ipairs(self._xValuesFiltered) do
		local y = self._yValueFunc(x)
		yMin = min(yMin or math.huge, y)
		yMax = max(yMax or -math.huge, y)
	end
	return self._xMin, self._xMax
end

function Graph.GetYRange(self)
	local yMin, yMax = nil, nil
	for _, x in ipairs(self._xValuesFiltered) do
		local y = self._yValueFunc(x)
		yMin = min(yMin or math.huge, y)
		yMax = max(yMax or -math.huge, y)
	end
	return yMin, yMax
end

function Graph.SetYValueFunction(self, func)
	self._yValueFunc = func
	return self
end

--- Sets functions for formatting values.
-- @tparam Graph self The graph object
-- @tparam function xFormatFunc A function which is passed an x value and returns a formatted string
-- @tparam function yFormatFunc A function which is passed a y value and returns a formatted string
-- @treturn Graph The graph object
function Graph.SetFormatFunctions(self, xFormatFunc, yFormatFunc)
	self._xFormatFunc = xFormatFunc
	self._yFormatFunc = yFormatFunc
	return self
end

--- Registers a script handler.
-- @tparam ScrollingTable self The graph object
-- @tparam string script The script to register for (supported scripts: `OnZoomChanged`)
-- @tparam function handler The script handler which will be called with the graph object followed by any
-- arguments to the script
-- @treturn Graph The graph object
function Graph.SetScript(self, script, handler)
	if script == "OnZoomChanged" then
		self._onZoomChanged = handler
	elseif script == "OnHoverUpdate" then
		self._onHoverUpdate = handler
	else
		error("Unknown Graph script: "..tostring(script))
	end
	return self
end

function Graph.Draw(self)
	self.__super:Draw()
	self:_ReleaseAllTextures()
	self:_ReleaseAllFontStrings()
	local frame = self:_GetBaseFrame()
	frame:SetBackdropColor(Theme.GetColor("PRIMARY_BG"):GetFractionalRGBA())
	local plot = frame.plot
	plot.hoverText:SetFont(Theme.GetFont("TABLE_TABLE1"):GetWowFont())
	plot.hoverText:SetTextColor(Theme.GetColor("INDICATOR_ALT"):GetFractionalRGBA())

	local plotWidth = plot:GetWidth()
	local plotHeight = plot:GetHeight()

	-- update the filtered set of x values to show and the bounds of the plot data
	self:_PopulateFilteredData(plotWidth)

	-- calculate the min and max y values which should be shown
	self._yMin, self._yMax = self._yStepFunc("RANGE", self._yMin, self._yMax, floor(plotHeight / PLOT_MIN_Y_LINE_SPACING))
	if Math.IsNan(self._yMax) then
		-- this happens when we're resizing the application frame
		return
	end

	-- draw the y axis lines and labels
	local prevYAxisOffset = -math.huge
	local yAxisValue = self._yMin
	while yAxisValue <= self._yMax do
		local yAxisOffset = Math.Scale(yAxisValue, self._yMin, self._yMax, 0, plotHeight)
		if not prevYAxisOffset or (yAxisOffset - prevYAxisOffset) >= PLOT_MIN_Y_LINE_SPACING then
			self:_DrawYAxisLine(yAxisOffset, yAxisValue, plotWidth, plotHeight)
			prevYAxisOffset = yAxisOffset
		end
		yAxisValue = self._yStepFunc("NEXT", yAxisValue, self._yMax)
	end

	-- draw the x axis lines and labels
	local xSuggestedStep = Math.Scale(PLOT_MIN_X_LINE_SPACING, 0, plotWidth, 0, self._zoomEnd - self._zoomStart)
	local prevXAxisOffset = -math.huge
	local xAxisValue = self._xStepFunc(self._zoomStart, xSuggestedStep)
	while xAxisValue <= self._zoomEnd do
		local xAxisOffset = Math.Scale(xAxisValue, self._zoomStart, self._zoomEnd, 0, plotWidth)
		if not prevXAxisOffset or (xAxisOffset - prevXAxisOffset) > PLOT_MIN_X_LINE_SPACING then
			self:_DrawXAxisLine(xAxisOffset, xAxisValue, plotWidth, plotHeight, xSuggestedStep)
			prevXAxisOffset = xAxisOffset
		end
		xAxisValue = self._xStepFunc(xAxisValue, xSuggestedStep)
	end

	-- draw all the lines
	local color = nil
	if self._isMouseOver or self._selectionStartX then
		color = Theme.GetColor("INDICATOR_ALT")
	elseif self._yLookup[self._xValuesFiltered[1]] <= self._yLookup[self._xValuesFiltered[#self._xValuesFiltered]] then
		color = Theme.GetFeedbackColor("GREEN")
	else
		color = Theme.GetFeedbackColor("RED")
	end
	local xPrev, yPrev = nil, nil
	for _, x in ipairs(self._xValuesFiltered) do
		local y = self._yLookup[x]
		local xCoord = Math.Scale(x, self._zoomStart, self._zoomEnd, 0, plotWidth)
		local yCoord = Math.Scale(y, self._yMin, self._yMax, 0, plotHeight)
		if xPrev then
			self:_DrawFillLine(xPrev, yPrev, xCoord, yCoord, LINE_THICKNESS, plotHeight, color)
		end
		xPrev = xCoord
		yPrev = yCoord
	end
end



-- ============================================================================
-- Private Class Methods
-- ============================================================================

function Graph._PopulateFilteredData(self, plotWidth)
	wipe(self._xValuesFiltered)
	wipe(self._yLookup)
	self._yMin = math.huge
	self._yMax = -math.huge
	local minStep = Math.Ceil((self._zoomEnd - self._zoomStart) / min(plotWidth / 3, MAX_PLOT_POINTS), self._xStepInterval)
	local x = self._zoomStart
	while x <= self._zoomEnd do
		local prevX = self._xValuesFiltered[#self._xValuesFiltered]
		if not prevX or x == self._zoomEnd or (x - prevX > minStep and self._zoomEnd - x > minStep) then
			-- this is either the first / last point or a middle point which is sufficiently far from the previous and last points
			tinsert(self._xValuesFiltered, x)
			local y = self._yValueFunc(x)
			self._yMin = min(self._yMin, y)
			self._yMax = max(self._yMax, y)
			self._yLookup[x] = y
		end
		if x == self._zoomEnd then
			break
		end
		x = min(x + minStep, self._zoomEnd)
	end
end

function Graph._DrawYAxisLine(self, yOffset, yValue, plotWidth, plotHeight, ySuggestedStep)
	local line = self:_AcquireLine("ARTWORK")
	local thickness = LINE_THICKNESS
	local textureHeight = thickness * LINE_THICKNESS_RATIO
	-- trim the texture a bit on the left/right since it's not completely filled to the edges which is noticeable on long lines
	line:SetTexCoord(0.1, 1, 0.1, 0, 0.9, 1, 0.9, 0)
	line:SetPoint("BOTTOMLEFT", 0 - thickness / 2, yOffset - textureHeight / 2)
	line:SetPoint("TOPRIGHT", line:GetParent(), "BOTTOMLEFT", plotWidth + thickness / 2, yOffset + textureHeight / 2)
	line:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA())
	line:SetDrawLayer("BACKGROUND", 0)
	local text = self:_AcquireFontString(Theme.GetFont("TABLE_TABLE1"))
	text:SetJustifyH("RIGHT")
	local textYOffset = 0
	if PLOT_Y_LABEL_HEIGHT / 2 > yOffset then
		text:SetJustifyV("BOTTOM")
		textYOffset = max(PLOT_Y_LABEL_HEIGHT / 2 - yOffset, 0)
	elseif yOffset + PLOT_Y_LABEL_HEIGHT / 2 > plotHeight then
		text:SetJustifyV("TOP")
		textYOffset = plotHeight - yOffset - PLOT_Y_LABEL_HEIGHT / 2
	else
		text:SetJustifyV("MIDDLE")
	end
	text:SetPoint("RIGHT", line, "LEFT", -PLOT_Y_LABEL_MARGIN, textYOffset)
	text:SetSize(PLOT_Y_LABEL_WIDTH, PLOT_Y_LABEL_HEIGHT)
	text:SetText(self._yFormatFunc(yValue, ySuggestedStep))
end

function Graph._DrawXAxisLine(self, xOffset, xValue, plotWidth, plotHeight, xSuggestedStep)
	local line = self:_AcquireLine("ARTWORK")
	local thickness = LINE_THICKNESS
	local textureHeight = thickness * LINE_THICKNESS_RATIO
	-- trim the texture a bit on the left/right since it's not completely filled to the edges which is noticeable on long lines
	line:SetTexCoord(0.9, 1, 0.1, 1, 0.9, 0, 0.1, 0)
	line:SetPoint("BOTTOMLEFT", xOffset - textureHeight / 2, thickness / 2)
	line:SetPoint("TOPRIGHT", line:GetParent(), "BOTTOMLEFT", xOffset + textureHeight / 2, plotHeight + thickness / 2)
	line:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA())
	line:SetDrawLayer("BACKGROUND", 0)
	local text = self:_AcquireFontString(Theme.GetFont("BODY_BODY3_MEDIUM"))
	text:ClearAllPoints()
	text:SetJustifyV("TOP")
	local textXOffset = 0
	if PLOT_X_LABEL_WIDTH / 2 > xOffset then
		text:SetJustifyH("LEFT")
		textXOffset = max(PLOT_X_LABEL_WIDTH / 2 - xOffset, 0)
	elseif xOffset + PLOT_X_LABEL_WIDTH / 2 > plotWidth then
		text:SetJustifyH("RIGHT")
		textXOffset = plotWidth - xOffset - PLOT_X_LABEL_WIDTH / 2
	else
		text:SetJustifyH("CENTER")
	end
	text:SetPoint("TOP", line, "BOTTOM", textXOffset, -PLOT_X_LABEL_MARGIN)
	text:SetSize(PLOT_X_LABEL_WIDTH, PLOT_X_LABEL_HEIGHT)
	text:SetText(self._xFormatFunc(xValue, xSuggestedStep))
end

function Graph._DrawFillLine(self, xFrom, yFrom, xTo, yTo, thickness, plotHeight, color)
	assert(xFrom <= xTo)
	local line = self:_AcquireLine("ARTWORK")
	local textureHeight = thickness * LINE_THICKNESS_RATIO
	local xDiff = xTo - xFrom
	local yDiff = yTo - yFrom
	local length = sqrt(xDiff * xDiff + yDiff * yDiff)
	local sinValue = -yDiff / length
	local cosValue = xDiff / length
	local sinCosValue = sinValue * cosValue
	local aspectRatio = length / textureHeight
	local invAspectRatio = textureHeight / length

	-- calculate and set tex coords
	local LLx, LLy, ULx, ULy, URx, URy, LRx, LRy = nil, nil, nil, nil, nil, nil, nil, nil
	if yDiff >= 0 then
		LLx = invAspectRatio * sinCosValue
		LLy = sinValue * sinValue
		LRy = aspectRatio * sinCosValue
		LRx = 1 - LLy
		ULx = LLy
		ULy = 1 - LRy
		URx = 1 - LLx
		URy = LRx
	else
		LLx = sinValue * sinValue
		LLy = -aspectRatio * sinCosValue
		LRx = 1 + invAspectRatio * sinCosValue
		LRy = LLx
		ULx = 1 - LRx
		ULy = 1 - LLx
		URy = 1 - LLy
		URx = ULy
	end
	line:SetTexCoord(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy)

	-- calculate and set texture anchors
	local xCenter = (xFrom + xTo) / 2
	local yCenter = (yFrom + yTo) / 2
	local halfWidth = (xDiff + invAspectRatio * abs(yDiff) + thickness) / 2
	local halfHeight = (abs(yDiff) + invAspectRatio * xDiff + thickness) / 2
	line:SetPoint("BOTTOMLEFT", xCenter - halfWidth, yCenter - halfHeight)
	line:SetPoint("TOPRIGHT", line:GetParent(), "BOTTOMLEFT", xCenter + halfWidth, yCenter + halfHeight)

	local minY = min(yFrom, yTo)
	local maxY = max(yFrom, yTo)
	local r, g, b, a = color:GetFractionalRGBA()
	local barMaxAlpha = Math.Scale(minY, 0, plotHeight, 0, MAX_FILL_ALPHA * a)
	local topMaxAlpha = Math.Scale(maxY, 0, plotHeight, 0, MAX_FILL_ALPHA * a)
	line:SetVertexColor(r, g, b, a)

	local fillTop = self:_AcquireTexture("ARTWORK", -1)
	fillTop:SetTexture("Interface\\AddOns\\TradeSkillMaster\\Media\\triangle")
	if yFrom < yTo then
		fillTop:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1)
	else
		fillTop:SetTexCoord(1, 0, 1, 1, 0, 0, 0, 1)
	end
	fillTop:SetGradientAlpha("VERTICAL", r, g, b, barMaxAlpha, r, g, b, topMaxAlpha)
	fillTop:SetPoint("BOTTOMLEFT", xFrom, minY)
	fillTop:SetPoint("TOPRIGHT", fillTop:GetParent(), "BOTTOMLEFT", xTo, maxY)

	local fillBar = self:_AcquireTexture("ARTWORK", -1)
	fillBar:SetTexture("Interface\\Buttons\\WHITE8X8")
	fillBar:SetGradientAlpha("VERTICAL", r, g, b, 0, r, g, b, barMaxAlpha)
	fillBar:SetPoint("BOTTOMLEFT", xFrom, 0)
	fillBar:SetPoint("TOPRIGHT", fillBar:GetParent(), "BOTTOMLEFT", xTo, minY)

	return line
end

function Graph._AcquireLine(self, layer, subLayer)
	local line = self:_AcquireTexture(layer, subLayer)
	line:SetTexture("Interface\\AddOns\\TradeSkillMaster\\Media\\line.tga")
	return line
end

function Graph._AcquireTexture(self, layer, subLayer)
	local plot = self:_GetBaseFrame().plot
	local result = tremove(self._freeTextures) or plot:CreateTexture()
	tinsert(self._usedTextures, result)
	result:SetParent(plot)
	result:Show()
	result:SetDrawLayer(layer, subLayer)
	return result
end

function Graph._ReleaseAllTextures(self)
	while #self._usedTextures > 0 do
		local texture = tremove(self._usedTextures)
		texture:SetTexture(nil)
		texture:SetVertexColor(0, 0, 0, 0)
		texture:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1)
		texture:SetWidth(0)
		texture:SetHeight(0)
		texture:ClearAllPoints()
		texture:Hide()
		tinsert(self._freeTextures, texture)
	end
end

function Graph._AcquireFontString(self, font)
	local plot = self:_GetBaseFrame().plot
	local result = tremove(self._freeFontStrings) or plot:CreateFontString()
	tinsert(self._usedFontStrings, result)
	result:SetParent(plot)
	result:Show()
	result:SetFont(font:GetWowFont())
	result:SetTextColor(Theme.GetColor("TEXT"):GetFractionalRGBA())
	return result
end

function Graph._ReleaseAllFontStrings(self)
	while #self._usedFontStrings > 0 do
		local fontString = tremove(self._usedFontStrings)
		fontString:SetWidth(0)
		fontString:SetHeight(0)
		fontString:ClearAllPoints()
		fontString:Hide()
		tinsert(self._freeFontStrings, fontString)
	end
end

function Graph._GetCursorClosestPoint(self)
	local plotFrame = self:_GetBaseFrame().plot
	local xPos = GetCursorPosition() / plotFrame:GetEffectiveScale()
	local fromMin = plotFrame:GetLeft()
	local fromMax = plotFrame:GetRight()
	-- Convert the cursor position to be relative to the plotted x values
	xPos = Math.Scale(Math.Bound(xPos, fromMin, fromMax), fromMin, fromMax, self._zoomStart, self._zoomEnd)
	-- Find the closest point to the cursor (based on the x distance)
	local closestX, closestY = nil, nil
	for _, x in ipairs(self._xValuesFiltered) do
		local y = self._yLookup[x]
		local xDist = abs(x - xPos)
		if not closestX or xDist < abs(closestX - xPos) then
			closestX = x
			closestY = y
		end
	end
	assert(closestY)
	return closestX, closestY
end

function Graph._XValueToPlotCoord(self, xValue)
	local plotFrame = self:_GetBaseFrame().plot
	return Math.Scale(xValue, self._zoomStart, self._zoomEnd, 0, plotFrame:GetWidth())
end



-- ============================================================================
-- Private Helper Functions
-- ============================================================================

function private.PlotFrameOnEnter(self)
	self._isMouseOver = true
	self:Draw()
	local plotFrame = self:_GetBaseFrame().plot
	ScriptWrapper.Set(plotFrame, "OnUpdate", private.PlotFrameOnUpdate, self)
end

function private.PlotFrameOnLeave(self)
	self._isMouseOver = false
end

function private.PlotFrameOnUpdate(self)
	local plotFrame = self:_GetBaseFrame().plot
	local closestX, closestY = self:_GetCursorClosestPoint()
	local xCoord = self:_XValueToPlotCoord(closestX)
	local yCoord = Math.Scale(closestY, self._yMin, self._yMax, 0, plotFrame:GetHeight())

	if self._isMouseOver then
		plotFrame.dot:Show()
		plotFrame.dot:ClearAllPoints()
		plotFrame.dot:SetPoint("CENTER", plotFrame, "BOTTOMLEFT", xCoord, yCoord)

		plotFrame.hoverLine:Show()
		plotFrame.hoverLine:SetColorTexture(Theme.GetColor("INDICATOR_ALT"):GetFractionalRGBA())
		plotFrame.hoverLine:ClearAllPoints()
		plotFrame.hoverLine:SetPoint("TOP", plotFrame, "TOPLEFT", xCoord, 0)
		plotFrame.hoverLine:SetPoint("BOTTOM", plotFrame, "BOTTOMLEFT", xCoord, 0)

		plotFrame.hoverText:Show()
		plotFrame.hoverText:SetWidth(1000)
		plotFrame.hoverText:SetText(self._yFormatFunc(closestY, nil, true))
		local textWidth = plotFrame.hoverText:GetStringWidth()
		plotFrame.hoverText:SetWidth(textWidth)
		plotFrame.hoverText:ClearAllPoints()
		if xCoord - textWidth / 2 < 0 then
			plotFrame.hoverText:SetPoint("BOTTOMLEFT", plotFrame, "TOPLEFT", 0, PLOT_Y_MARGIN)
		elseif textWidth / 2 + xCoord > plotFrame:GetWidth() then
			plotFrame.hoverText:SetPoint("BOTTOMRIGHT", plotFrame, "TOPRIGHT", 0, PLOT_Y_MARGIN)
		else
			plotFrame.hoverText:SetPoint("BOTTOM", plotFrame, "TOPLEFT", xCoord, PLOT_Y_MARGIN)
		end
	else
		plotFrame.dot:Hide()
		plotFrame.hoverLine:Hide()
		plotFrame.hoverText:Hide()
	end

	if self._selectionStartX then
		local startXCoord = self:_XValueToPlotCoord(self._selectionStartX)
		local selectionMinX = min(startXCoord, xCoord)
		local selectionMaxX = max(startXCoord, xCoord)
		plotFrame.selectionBox:Show()
		local r, g, b, a = Theme.GetColor("INDICATOR_ALT"):GetFractionalRGBA()
		assert(a == 1)
		plotFrame.selectionBox:SetColorTexture(r, g, b, SELECTION_ALPHA)
		plotFrame.selectionBox:ClearAllPoints()
		plotFrame.selectionBox:SetPoint("TOPLEFT", plotFrame, selectionMinX, 0)
		plotFrame.selectionBox:SetPoint("BOTTOMRIGHT", plotFrame, "BOTTOMLEFT", selectionMaxX, 0)
	else
		plotFrame.selectionBox:Hide()
	end

	local isHovered = self._isMouseOver or self._selectionStartX
	if not isHovered then
		self:Draw()
		ScriptWrapper.Clear(plotFrame, "OnUpdate")
	end
	if self._onHoverUpdate then
		self:_onHoverUpdate(isHovered and closestX or nil)
	end
end

function private.PlotFrameOnMouseDown(self, mouseButton)
	if mouseButton ~= "LeftButton" then
		return
	end
	assert(self._isMouseOver)
	self._selectionStartX = self:_GetCursorClosestPoint()
end

function private.PlotFrameOnMouseUp(self, mouseButton)
	if mouseButton ~= "LeftButton" then
		return
	end
	local currentX = self:_GetCursorClosestPoint()
	local startX = min(self._selectionStartX, currentX)
	local endX = max(self._selectionStartX, currentX)
	self._selectionStartX = nil
	local plotFrame = self:_GetBaseFrame().plot
	plotFrame.selectionBox:Hide()

	if startX ~= endX and (startX ~= self._zoomStart or endX ~= self._zoomEnd) then
		self._zoomStart = startX
		self._zoomEnd = endX
		self:Draw()
		if self._onZoomChanged then
			self:_onZoomChanged()
		end
	end
end
