/* +------------------------------------------------------------------------+
   |                     Mobile Robot Programming Toolkit (MRPT)            |
   |                          https://www.mrpt.org/                         |
   |                                                                        |
   | Copyright (c) 2005-2023, Individual contributors, see AUTHORS file     |
   | See: https://www.mrpt.org/Authors - All rights reserved.               |
   | Released under BSD License. See: https://www.mrpt.org/License          |
   +------------------------------------------------------------------------+ */

#include <mrpt/containers/find_closest.h>
//#include <mrpt/system/CTimeLogger.h>

#include <wx/tooltip.h>

#include "xRawLogViewerMain.h"

constexpr int TL_BORDER = 2;  // pixels
constexpr int TL_BORDER_BOTTOM = 14;  // pixels
constexpr int TL_X_TICK_COUNT = 7;
constexpr int CURSOR_WIDTH_PIXELS = 2;

constexpr int XTICKS_FONT_SIZE = 20;  // => 6x10 actual char, 9x10 incl spacing
constexpr double XTICKS_FONT_WIDTH = 65.0 / 7.0;

constexpr double TL_CLICK_SENSOR_LABEL_VERTICAL_TOLERANCE = 0.1;
constexpr int TL_SEARCH_CLICK_RANGE = 200;

constexpr double TL_MAXIMUM_TIME_ZOOM = 0.001;	// in [0,1] ratio

void xRawLogViewerFrame::createTimeLineObjects(wxFlexGridSizer* fgzMain)
{
	using This = xRawLogViewerFrame;  // shortcut!

	static const long ID_TIMELINE_SCROLL_BAR = wxNewId();

	wxFlexGridSizer* fgzBottomTimeLine;

	fgzBottomTimeLine = new wxFlexGridSizer(1, 1, 0, 0);
	fgzBottomTimeLine->AddGrowableRow(0);
	fgzBottomTimeLine->AddGrowableCol(0);

	// bottom-right/main panel 3D view:
	wxPanel* pnTimeLine;
	{
		static const long ID_PANEL_TIMELINE = wxNewId();
		static const long ID_TIMELINE_GLCANVAS = wxNewId();

		pnTimeLine = new wxPanel(
			this, ID_PANEL_TIMELINE, wxDefaultPosition, wxDefaultSize,
			wxTAB_TRAVERSAL, _T("ID_PANEL_TIMELINE"));
		auto fgs = new wxFlexGridSizer(2, 1, 0, 0);
		fgs->AddGrowableCol(0);
		fgs->AddGrowableRow(1);

		m_timeline.sbTimeLineRange =
			new wxScrollBar(pnTimeLine, ID_TIMELINE_SCROLL_BAR);

		m_timeline.sbTimeLineRange->Bind(
			wxEVT_SCROLL_CHANGED, &This::OnTimelineZoomScroolBar, this);
		m_timeline.sbTimeLineRange->Bind(
			wxEVT_SCROLL_THUMBTRACK, &This::OnTimelineZoomScroolBar, this);

		// scrollbar properties set in rebuildBottomTimeLine().

		fgs->Add(m_timeline.sbTimeLineRange, 1, wxEXPAND, 0 /*border*/);

		m_glTimeLine = new CMyGLCanvas(pnTimeLine, ID_TIMELINE_GLCANVAS);
		pnTimeLine->SetMinSize(wxSize(-1, 125));
		m_glTimeLine->SetMinSize(wxSize(-1, 125));
		fgs->Add(m_glTimeLine, 1, wxEXPAND, 2 /*border*/);

		pnTimeLine->SetSizer(fgs);
		fgs->SetSizeHints(pnTimeLine);

		fgzBottomTimeLine->Add(pnTimeLine, 1, wxEXPAND, 0);

		// timeline opengl area events:
		m_glTimeLine->Bind(wxEVT_MOTION, &This::OnTimeLineMouseMove, this);
		m_glTimeLine->Bind(wxEVT_LEFT_DOWN, &This::OnTimeLineMouseMove, this);
		m_glTimeLine->Bind(wxEVT_LEFT_UP, &This::OnTimeLineMouseLeftUp, this);
		m_glTimeLine->Bind(
			wxEVT_RIGHT_DOWN, &This::OnTimeLineMouseRightDown, this);
		m_glTimeLine->Bind(wxEVT_RIGHT_UP, &This::OnTimeLineMouseRightUp, this);

		m_glTimeLine->Bind(wxEVT_MOUSEWHEEL, &This::OnTimeLineMouseWheel, this);
	}

	fgzMain->Add(fgzBottomTimeLine, 1, wxEXPAND, 0);

	// Set-up bottom timeline view opengl objects:
	{
		mrpt::opengl::Scene::Ptr& scene = m_glTimeLine->getOpenGLSceneRef();
		scene->clear();

		// Enable no-projection mode in this viewport:
		scene->getViewport()->setCustomBackgroundColor({1.0f, 1.0f, 1.0f});

		m_glTimeLine->setUseCameraFromScene(true);

		{
			auto glCam = mrpt::opengl::CCamera::Create();
			glCam->setNoProjection();  // work with pixel coordinates
			scene->insert(glCam);
		}

		m_timeline.borderBox = mrpt::opengl::CBox::Create(
			mrpt::math::TPoint3D(-1.0, 0., 0.),
			mrpt::math::TPoint3D(0.9, 1., 0.), true);
		scene->insert(m_timeline.borderBox);

		m_timeline.xTicks = mrpt::opengl::CSetOfObjects::Create();
		scene->insert(m_timeline.xTicks);

		m_timeline.ySensorLabels = mrpt::opengl::CSetOfObjects::Create();
		scene->insert(m_timeline.ySensorLabels);

		m_timeline.allSensorDots = mrpt::opengl::CSetOfObjects::Create();
		scene->insert(m_timeline.allSensorDots);

		m_timeline.cursor = mrpt::opengl::CBox::Create();
		scene->insert(m_timeline.cursor);

		m_timeline.horizontalCursor = mrpt::opengl::CBox::Create();
		scene->insert(m_timeline.horizontalCursor);

		m_timeline.visiblePage = mrpt::opengl::CBox::Create();
		scene->insert(m_timeline.visiblePage);
	}

	// Bind(wxEVT_IDLE, &This::OnIdle, this);
	Bind(wxEVT_SIZE, &This::OnSize, this);
	Bind(wxEVT_MAXIMIZE, &This::OnMaximize, this);
}

// Resize and rebuild timeline view objects:
void xRawLogViewerFrame::rebuildBottomTimeLine()
{
#if 0
	static mrpt::system::CTimeLogger profiler;
	mrpt::system::CTimeLoggerEntry tle(profiler, "rebuildBottomTimeLine");
#endif

	auto& tl = m_timeline;
	tl.clearStats();

	const auto clsz = mrpt::gui::GetScaledClientSize(m_glTimeLine);

	auto px2x = [clsz](int u) { return -1.0 + (2.0 / clsz.GetWidth()) * u; };
	auto px2y = [clsz](int v) { return +1.0 - (2.0 / clsz.GetHeight()) * v; };

	auto px2width = [clsz](int u) { return (2.0 / clsz.GetWidth()) * u; };

	const double widthOf1Px = px2width(1);

	// make room for sensor labels on the left side:
	size_t maxSensorLabelLength = 10;
	for (const auto& e : listOfSensorLabels)
		mrpt::keep_max(maxSensorLabelLength, e.first.size());

	tl.actualLeftBorderPixels =
		mrpt::round(maxSensorLabelLength * XTICKS_FONT_WIDTH + 10);

	const double xLeft = px2x(tl.actualLeftBorderPixels);
	const double xRight = px2x(clsz.GetWidth() - TL_BORDER);
	const double yLowerBorder = px2y(clsz.GetHeight() - TL_BORDER_BOTTOM);
	const double yTopBorder = px2y(TL_BORDER);

	const double xLeft1 = px2x(tl.actualLeftBorderPixels + 1);
	const double xRight1 = px2x(clsz.GetWidth() - TL_BORDER - 1);
	const double yLowerBorder1 = px2y(clsz.GetHeight() - TL_BORDER_BOTTOM - 1);
	const double yTopBorder1 = px2y(TL_BORDER + 1);

	const double yLowerBorder2 = px2y(clsz.GetHeight() - TL_BORDER_BOTTOM - 5);
	const double yTopBorder2 = px2y(TL_BORDER + 5);

	// outer border box:
	tl.borderBox->setBoxCorners(
		mrpt::math::TPoint3D(xLeft, yLowerBorder, 0),
		mrpt::math::TPoint3D(xRight, yTopBorder, 0));

	// find time limits:
	auto& min_t = tl.min_t;
	auto& max_t = tl.max_t;

	for (const auto& e : listOfSensorLabels)
	{
		if (e.second.timOccurs.empty()) continue;

		for (const auto& t : e.second.timOccurs)
		{
			if (min_t == INVALID_TIMESTAMP || t < min_t) { min_t = t; }
			if (max_t == INVALID_TIMESTAMP || t > max_t) { max_t = t; }
		}
	}

	// x ticks:
	tl.xTicks->clear();

	// absolute limits of dataset:
	const double abs_min_t_d = mrpt::Clock::toDouble(min_t);
	const double abs_max_t_d = mrpt::Clock::toDouble(max_t);
	const double absDatasetDuration = std::max(1.0, abs_max_t_d - abs_min_t_d);

	// Zoomed-in time range:
	const double min_t_d =
		abs_min_t_d + m_timeline.scrollBarStartPercent * absDatasetDuration;
	const double max_t_d =
		min_t_d + m_timeline.scrollBarZoomVisiblePercent * absDatasetDuration;

	// Scrollbar properties:
	m_timeline.sbTimeLineRange->SetScrollbar(
		// thumb position:
		mrpt::round(absDatasetDuration * m_timeline.scrollBarStartPercent),
		// thumbSize: visible part (seconds).
		mrpt::round(
			absDatasetDuration * m_timeline.scrollBarZoomVisiblePercent),
		// range:
		mrpt::round(absDatasetDuration),
		// page size
		1);

	for (int i = 0; i < (TL_X_TICK_COUNT - 1); i++)
	{
		// tick label:
		auto glLb = mrpt::opengl::CText::Create();
		glLb->setFont("mono", XTICKS_FONT_SIZE);
		glLb->setString(mrpt::system::formatTimeInterval(
			i * (max_t_d - min_t_d) / (TL_X_TICK_COUNT - 1) + min_t_d -
			abs_min_t_d));
		glLb->setColor_u8(0x00, 0x00, 0x00, 0xff);

		const double ptX =
			xLeft1 + i * (xRight1 - xLeft1) / (TL_X_TICK_COUNT - 1);
		glLb->setLocation(ptX, px2y(clsz.GetHeight() - 1), 0);

		tl.xTicks->insert(glLb);

		// tick line:
		auto glTick = mrpt::opengl::CSimpleLine::Create();
		glTick->setColor_u8(0xa0, 0xa0, 0xa0, 0x80);
		glTick->setLineCoords(	//
			ptX, yLowerBorder1, 0,	//
			ptX, yTopBorder1, 0	 //
		);
		tl.xTicks->insert(glTick);
	}

	// Main per-sensor points:
	tl.allSensorDots->clear();
	tl.yCoordToSensorLabel.clear();
	tl.ySensorLabels->clear();

	if (!listOfSensorLabels.empty())
	{
		const double dy = (yTopBorder2 - yLowerBorder2) /
			(listOfSensorLabels.size() > 1 ? listOfSensorLabels.size() : 1.0);
		double y0 = yLowerBorder2 + 0.1 * dy;

		for (const auto& e : listOfSensorLabels)
		{
			if (e.second.timOccurs.empty()) continue;

			auto glDots = mrpt::opengl::CPointCloud::Create();
			tl.allSensorDots->insert(glDots);

			glDots->setColor_u8(0x00, 0x00, 0xff, 0xff);
			glDots->setPointSize(1.0f);
			glDots->enableVariablePointSize(false);
			glDots->setName(e.first);

			double lastX = -2;	// actual coords go in [-1,1]
			for (const auto& tim : e.second.timOccurs)
			{
				const double t = mrpt::Clock::toDouble(tim);

				const double dx = (t - min_t_d) / (max_t_d - min_t_d);
				if (dx < 0 || dx > 1) continue;	 // out of zoomed-in area

				const double x = xLeft1 + dx * (xRight1 - xLeft1);

				if (x - lastX < widthOf1Px)
					continue;  // no worth adding so many points

				lastX = x;
				glDots->insertPoint(x, y0, 0);
			}

			// Keep a map between vertical coords and sensor labels:
			tl.yCoordToSensorLabel.insert(y0, e.first);

			// and add its visualization:
			if (!e.first.empty())
			{
				auto glLb = mrpt::opengl::CText::Create();
				glLb->setFont("mono", XTICKS_FONT_SIZE);
				glLb->setString(e.first);
				glLb->setColor_u8(0x00, 0x00, 0x00, 0xff);

#if 0
				// right-aligned text:
				const double ptX = xLeft - 4 * widthOf1Px -
					px2width(XTICKS_FONT_WIDTH * e.first.size() - 1);
#endif
				const double ptX = -1.0 + 4 * widthOf1Px;
				glLb->setLocation(ptX, y0, 0);

				glLb->setName(e.first);

				tl.ySensorLabels->insert(glLb);
			}

			y0 += dy;
		}
	}

	// Build x <-> treeIndex map:
	tl.xs2treeIndices.clear();
	tl.treeIndices2xs.clear();
	{
		double lastX = -2;	// actual coords go in [-1,1]
		for (size_t idx = 0; idx < m_treeView->getTotalTreeNodes(); idx++)
		{
			const auto& tim = m_treeView->treeNodes()[idx].timestamp;
			if (!tim.has_value()) continue;

			const double t = mrpt::Clock::toDouble(*tim);

			const double dx = (t - min_t_d) / (max_t_d - min_t_d);
			if (dx < 0 || dx > 1) continue;	 // out of zoomed-in area

			const double x = xLeft1 + dx * (xRight1 - xLeft1);

			// Insert all indices, without the decimation below:
			tl.treeIndices2xs[idx] = x;

			if (x - lastX < widthOf1Px)
				continue;  // decimation: no worth adding so many points

			lastX = x;

			tl.xs2treeIndices.insert(tl.xs2treeIndices.end(), {x, idx});
		}
	}

	// current time position page:
	tl.visiblePage->setColor_u8(0xff, 0x00, 0x00, 0x20);
	tl.visiblePage->setBoxBorderColor({0xff, 0x00, 0x00, 0x20});

	tl.horizontalCursor->setColor_u8(0xc0, 0x00, 0x00, 0x20);
	tl.horizontalCursor->setBoxBorderColor({0xc0, 0x00, 0x00, 0x20});

	tl.cursor->setColor_u8(0x30, 0x30, 0x30, 0x50);
	tl.cursor->setBoxBorderColor({0x30, 0x30, 0x30, 0x50});

	bottomTimeLineUpdateCursorFromTreeScrollPos();	// this does a Refresh()
}

void xRawLogViewerFrame::bottomTimeLineUpdateCursorFromTreeScrollPos()
{
	if (rawlog.empty())
	{
		m_timeline.visiblePage->setVisibility(false);
		m_timeline.cursor->setVisibility(false);
		m_timeline.horizontalCursor->setVisibility(false);
		return;
	}

	const auto clsz = mrpt::gui::GetScaledClientSize(m_glTimeLine);

	auto px2x = [clsz](int u) { return -1.0 + (2.0 / clsz.GetWidth()) * u; };
	auto px2y = [clsz](int v) { return +1.0 - (2.0 / clsz.GetHeight()) * v; };

	auto px2width = [clsz](int u) { return (2.0 / clsz.GetWidth()) * u; };

	const double widthOf1Px = px2width(1);

	const double xLeft1 = px2x(m_timeline.actualLeftBorderPixels + 1);
	const double xRight1 = px2x(clsz.GetWidth() - TL_BORDER - 1);
	const double yLowerBorder1 = px2y(clsz.GetHeight() - TL_BORDER_BOTTOM - 1);
	const double yTopBorder1 = px2y(TL_BORDER + 1);

	// percent of view:
	double pc0 = 0, pc1 = 0;
	if (const double nItems = m_treeView->getTotalTreeNodes(); nItems > 2)
	{
		pc0 = m_treeView->m_firstVisibleItem / static_cast<double>(nItems - 1);
		pc1 = m_treeView->m_lastVisibleItem / static_cast<double>(nItems - 1);
	}

	// visible page shaded area:
	if (auto itIdx = m_timeline.treeIndices2xs.lower_bound(
			m_treeView->m_firstVisibleItem);
		itIdx != m_timeline.treeIndices2xs.end())
	{
		double xVisPage0 = itIdx->second;  // the "x"
		double xVisPage1 = xVisPage0 +
			(xRight1 - xLeft1) * (pc1 - pc0) /
				m_timeline.scrollBarZoomVisiblePercent;

		mrpt::keep_max(xVisPage1, xVisPage0 + 2 * widthOf1Px);	// Minimum width

		// borders for zoomed-in views.
		mrpt::keep_max(xVisPage1, xLeft1);
		mrpt::keep_min(xVisPage1, xRight1);

		mrpt::keep_max(xVisPage0, xLeft1);
		mrpt::keep_min(xVisPage0, xRight1);

		m_timeline.visiblePage->setVisibility(true);
		m_timeline.visiblePage->setBoxCorners(
			mrpt::math::TPoint3D(xVisPage0, yLowerBorder1, 0),	//
			mrpt::math::TPoint3D(xVisPage1, yTopBorder1, 0));
	}
	else
	{
		m_timeline.visiblePage->setVisibility(false);
	}

	// selected cursor line:
	if (auto itIdxCursor =
			m_timeline.treeIndices2xs.find(m_treeView->GetSelectedItem());
		itIdxCursor != m_timeline.treeIndices2xs.end())
	{
		const double cursorWidth = px2width(CURSOR_WIDTH_PIXELS);

		double xCursor0 = itIdxCursor->second;	// the "x"
		double xCursor1 = xCursor0 + cursorWidth;

		m_timeline.cursor->setVisibility(true);
		m_timeline.cursor->setBoxCorners(
			mrpt::math::TPoint3D(xCursor0, yLowerBorder1, 0),  //
			mrpt::math::TPoint3D(xCursor1, yTopBorder1, 0));
	}
	else
	{
		m_timeline.cursor->setVisibility(false);
	}

	m_glTimeLine->Refresh();
	// DONT: wxTheApp->Yield();
}

void xRawLogViewerFrame::OnTimeLineDoScrollToMouseX(
	const std::optional<std::pair<double, size_t>>& selPt)
{
	if (selPt.has_value())
	{
		auto treeIndex = selPt->second;

		m_treeView->m_is_thumb_tracking = true;
		m_treeView->ScrollToPercent(
			treeIndex /
			static_cast<double>(m_treeView->getTotalTreeNodes() - 1));
	}
}

void xRawLogViewerFrame::OnTimeLineMouseMove(wxMouseEvent& e)
{
	if (e.RightIsDown())
	{
		std::optional<std::pair<double, size_t>> selPt =
			timeLineMouseXYToTreeIndex(e);
		OnTimeLineDoScrollToMouseX(selPt);
	}
	else if (e.LeftIsDown())
	{
		std::optional<std::pair<double, size_t>> selPt;

		if (!m_timeline.currentTrackedSensorLabel.has_value())
		{
			// select on first click:
			std::string sel;
			selPt = timeLineMouseXYToTreeIndex(e, true, std::nullopt, sel);
			if (!sel.empty()) m_timeline.currentTrackedSensorLabel = sel;
		}
		else
		{
			// continue tracking the same sensor:
			selPt = timeLineMouseXYToTreeIndex(
				e, true, *m_timeline.currentTrackedSensorLabel);
		}

		OnTimeLineMouseLeftDown(selPt);
	}
	else
	{
		static double lastUpdate = .0;
		const double t = mrpt::Clock::nowDouble();
		if (t - lastUpdate > 0.05)
		{
			lastUpdate = t;
			std::string sel;
			timeLineMouseXYToTreeIndex(e, true, std::nullopt, sel);
			bottomTimeLineUpdateCursorFromTreeScrollPos();	// Refresh()
		}
	}
}
void xRawLogViewerFrame::OnTimeLineMouseLeftDown(
	const std::optional<std::pair<double, size_t>>& selPt)
{
	if (selPt.has_value())
	{
		auto treeIndex = selPt->second;

		m_treeView->SetSelectedItem(treeIndex);
		if (!m_treeView->isItemIndexVisible(treeIndex))
		{
			m_treeView->ScrollToPercent(
				treeIndex /
				static_cast<double>(m_treeView->getTotalTreeNodes() - 1));
		}
	}
}
void xRawLogViewerFrame::OnTimeLineMouseLeftUp(wxMouseEvent&)
{
	m_timeline.currentTrackedSensorLabel.reset();

	m_treeView->m_is_thumb_tracking = false;
	m_treeView->Refresh();
}
void xRawLogViewerFrame::OnTimeLineMouseRightDown(wxMouseEvent& e)
{
	OnTimeLineMouseMove(e);
}
void xRawLogViewerFrame::OnTimeLineMouseRightUp(wxMouseEvent& e)
{
	OnTimeLineMouseLeftUp(e);
}

void xRawLogViewerFrame::OnTimeLineMouseWheel(wxMouseEvent& e)
{
	const auto wheelIncr = e.GetWheelRotation();

	if (!e.ShiftDown())
	{
		// Zoom in/out:
		const double incrRatio = 1.0 -
			0.2 * (std::max(1.1, std::abs(wheelIncr) / 100.0) - 1.0) *
				mrpt::sign(wheelIncr);

		const double R = m_timeline.scrollBarZoomVisiblePercent;

		m_timeline.scrollBarZoomVisiblePercent *= incrRatio;
		mrpt::keep_min(m_timeline.scrollBarZoomVisiblePercent, 1.0);
		mrpt::keep_max(
			m_timeline.scrollBarZoomVisiblePercent, TL_MAXIMUM_TIME_ZOOM);

		const double Rnew = m_timeline.scrollBarZoomVisiblePercent;

		// Try to keep the hover-on point in the same position before vs after
		// zoom:
		if (std::optional<std::pair<double, size_t>> selPt =
				timeLineMouseXYToTreeIndex(e, false);
			selPt.has_value())
		{
			double xFocus = selPt->first;
			mrpt::keep_max(xFocus, -1.0);
			mrpt::keep_min(xFocus, 1.0);

			const auto clsz = mrpt::gui::GetScaledClientSize(m_glTimeLine);

			auto px2x = [clsz](int u) {
				return -1.0 + (2.0 / clsz.GetWidth()) * u;
			};
			const double xLeft1 = px2x(m_timeline.actualLeftBorderPixels + 1);
			const double xRight1 = px2x(clsz.GetWidth() - TL_BORDER - 1);

			const double c = (xFocus - xLeft1) / (xRight1 - xLeft1);

			// -------------------------------------------------------
			// Equation for keeping the same focused point:
			// s'+c*R' = s+c*R
			// -------------------------------------------------------
			const double s = m_timeline.scrollBarStartPercent;

			const double sNew = s + c * (R - Rnew);

			m_timeline.scrollBarStartPercent = sNew;
		}
	}
	else
	{
		// Pan left/right:
		const double incrRatio = -0.2 *
			(std::max(1.1, std::abs(wheelIncr) / 100.0) - 1.0) *
			mrpt::sign(wheelIncr) * m_timeline.scrollBarZoomVisiblePercent;

		m_timeline.scrollBarStartPercent += incrRatio;
	}

	// limits:
	mrpt::keep_max(m_timeline.scrollBarStartPercent, 0.0);
	mrpt::keep_min(
		m_timeline.scrollBarStartPercent,
		1.0 - m_timeline.scrollBarZoomVisiblePercent);

	// Update
	rebuildBottomTimeLine();
}

void xRawLogViewerFrame::OnTimelineZoomScroolBar(const wxScrollEvent&)
{
	int pos = m_timeline.sbTimeLineRange->GetThumbPosition();
	int maxRange = m_timeline.sbTimeLineRange->GetRange();
	m_timeline.scrollBarStartPercent = static_cast<double>(pos) / maxRange;

	rebuildBottomTimeLine();
}

std::optional<std::pair<double, size_t>>
	xRawLogViewerFrame::timeLineMouseXYToTreeIndex(
		const wxMouseEvent& e, bool refineBySensorLabelVerticalMatch,
		const std::optional<std::string>& forceThisSensorLabel,
		const mrpt::optional_ref<std::string>& outSelectedSensorLabel) const
{
	const auto clsz = mrpt::gui::GetScaledClientSize(m_glTimeLine);

	auto& tl = m_timeline;

	const int mouseX = e.GetX();
	if (mouseX < 0 || mouseX >= clsz.GetWidth()) return {};

	const int mouseY = e.GetY();
	if (mouseY < 0 || mouseY >= clsz.GetHeight()) return {};

	auto px2x = [clsz](int u) { return -1.0 + (2.0 / clsz.GetWidth()) * u; };
	auto px2y = [clsz](int v) { return +1.0 - (2.0 / clsz.GetHeight()) * v; };

	double clickedX = px2x(mouseX);
	double clickedY = px2y(mouseY);

	// find an approximate sensor type by vertical position:
	std::optional<std::string> trackedSensorLabel;

	if (forceThisSensorLabel.has_value())
	{  //
		trackedSensorLabel = *forceThisSensorLabel;
	}
	else
	{
		auto closestSensorLabel = mrpt::containers::find_closest_with_tolerance(
			tl.yCoordToSensorLabel.getDirectMap(), clickedY,
			TL_CLICK_SENSOR_LABEL_VERTICAL_TOLERANCE);

		if (closestSensorLabel.has_value())
		{
			trackedSensorLabel = closestSensorLabel->second;

			if (outSelectedSensorLabel)
				outSelectedSensorLabel.value().get() = *trackedSensorLabel;
		}
	}

	// vertical line highlight:
	for (size_t i = 0;; i++)
	{
		auto glLb = tl.ySensorLabels->getByClass<mrpt::opengl::CText>(i);
		if (!glLb) break;

		if (trackedSensorLabel.has_value() &&
			glLb->getString() == *trackedSensorLabel)
		{
			glLb->setColor_u8(0xff, 0x00, 0x00, 0xff);
		}
		else
		{
			glLb->setColor_u8(0x00, 0x00, 0x00, 0xff);
		}
	}

	// bold dots in selected line:
	for (size_t i = 0;; i++)
	{
		auto glPts = tl.allSensorDots->getByClass<mrpt::opengl::CPointCloud>(i);
		if (!glPts) break;

		glPts->setPointSize(
			(trackedSensorLabel.has_value() &&
			 glPts->getName() == *trackedSensorLabel)
				? 4.0f
				: 1.0f);
	}

	m_timeline.horizontalCursor->setVisibility(false);
	if (trackedSensorLabel.has_value())
	{
		const double xLeft1 = px2x(tl.actualLeftBorderPixels + 1);
		const double xRight1 = px2x(clsz.GetWidth() - TL_BORDER - 1);

		auto px2height = [clsz](int v) { return (2.0 / clsz.GetHeight()) * v; };

		const double heightOf1Px = px2height(1);

		const auto& im = tl.yCoordToSensorLabel.getInverseMap();
		if (auto it = im.find(*trackedSensorLabel); it != im.end())
		{
			const double y = it->second;
			const double ym1 = y - heightOf1Px;
			const double yp1 = y + heightOf1Px;

			m_timeline.horizontalCursor->setVisibility(true);
			m_timeline.horizontalCursor->setBoxCorners(
				mrpt::math::TPoint3D(xLeft1, ym1, 0),
				mrpt::math::TPoint3D(xRight1, yp1, 0));
		}
	}

	// ---
	std::string matchedClickSensorLabel;

	if (auto closestXIdx =
			mrpt::containers::find_closest(m_timeline.xs2treeIndices, clickedX);
		closestXIdx.has_value() &&
		m_timeline.treeIndices2xs.count(closestXIdx->second) != 0)
	{
		// Closest sensor by "x":
		auto treeIndex = closestXIdx->second;

		mrpt::keep_max(treeIndex, 0U);
		mrpt::keep_min(treeIndex, m_treeView->getTotalTreeNodes() - 1);

		// Look in the neighbors for the exact sensorLabel:
		if (trackedSensorLabel.has_value() && refineBySensorLabelVerticalMatch)
		{
			const std::string& clickedSensorLabel = *trackedSensorLabel;

			const int lastIdx = m_treeView->getTotalTreeNodes() - 1;

			const auto lambdaTest = [&treeIndex, this, clickedSensorLabel,
									 lastIdx, &matchedClickSensorLabel](int i) {
				if (i < 0) return false;
				if (i > lastIdx) return false;

				const auto& sl = m_treeView->treeNodes().at(i).sensorLabel;

				if (!sl.has_value()) return false;

				bool found = sl.value() == clickedSensorLabel;

				if (found && m_timeline.treeIndices2xs.count(i) != 0)
				{
					treeIndex = i;	// keep best match.
					matchedClickSensorLabel = clickedSensorLabel;
				}

				return found;
			};

			const int ti0 = static_cast<int>(treeIndex);

			for (int dist = 0; dist < TL_SEARCH_CLICK_RANGE; dist++)
			{
				if (bool found = lambdaTest(ti0 + dist); found) break;
				if (bool found = lambdaTest(ti0 - dist); found) break;
			}
		}

		if (!matchedClickSensorLabel.empty())
		{
			m_glTimeLine->SetToolTip(matchedClickSensorLabel);
			m_glTimeLine->GetToolTip()->SetReshow(10);
			m_glTimeLine->GetToolTip()->SetDelay(10);
		}
		else
		{
			m_glTimeLine->UnsetToolTip();
		}

		// Search "x" in the treeIndices2xs again for the case where treeIndex
		// has been modified by the search-by-sensorLabel loop above
		return {{m_timeline.treeIndices2xs.at(treeIndex), treeIndex}};
	}
	else
	{
		return {};
	}
}
