diff --git a/src/application/ScenarioCanvasWidget.cpp b/src/application/ScenarioCanvasWidget.cpp index d0f748a..94fbb37 100644 --- a/src/application/ScenarioCanvasWidget.cpp +++ b/src/application/ScenarioCanvasWidget.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -55,6 +56,66 @@ bool matchesFloor(const std::string& elementFloorId, const QString& floorId) { return floorId.isEmpty() || elementFloorId.empty() || QString::fromStdString(elementFloorId) == floorId; } +QString formatConnectionBlockTooltip(const safecrowd::domain::ConnectionBlockDraft& block) { + if (block.connectionId.empty()) { + return {}; + } + + QString text = QStringLiteral("Block schedule"); + if (block.intervals.empty()) { + text.append("\n- Always"); + return text; + } + + for (const auto& interval : block.intervals) { + const auto start = std::max(0.0, interval.startSeconds); + const auto end = std::max(start, interval.endSeconds); + text.append(QString("\n- %1s ~ %2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } + return text; +} + +std::optional hoveredConnectionBlockIndex( + const safecrowd::domain::FacilityLayout2D& layout, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const QString& currentFloorId, + const QPointF& screenPosition) { + constexpr double kHoverRadiusPixels = 14.0; + + std::optional closestIndex; + double closestDistanceSq = kHoverRadiusPixels * kHoverRadiusPixels; + + for (std::size_t index = 0; index < blocks.size(); ++index) { + const auto& block = blocks[index]; + if (block.connectionId.empty()) { + continue; + } + + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == block.connectionId; + }); + if (it == layout.connections.end()) { + continue; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + continue; + } + + const auto center = transform.map({.x = (it->centerSpan.start.x + it->centerSpan.end.x) * 0.5, + .y = (it->centerSpan.start.y + it->centerSpan.end.y) * 0.5}); + const auto dx = center.x() - screenPosition.x(); + const auto dy = center.y() - screenPosition.y(); + const auto distanceSq = (dx * dx) + (dy * dy); + if (distanceSq <= closestDistanceSq) { + closestDistanceSq = distanceSq; + closestIndex = index; + } + } + + return closestIndex; +} + QString defaultFloorId(const safecrowd::domain::FacilityLayout2D& layout) { if (!layout.floors.empty() && !layout.floors.front().id.empty()) { return QString::fromStdString(layout.floors.front().id); @@ -699,6 +760,12 @@ void ScenarioCanvasWidget::keyReleaseEvent(QKeyEvent* event) { QWidget::keyReleaseEvent(event); } +void ScenarioCanvasWidget::leaveEvent(QEvent* event) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + QWidget::leaveEvent(event); +} + void ScenarioCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { camera_.reset(); @@ -716,17 +783,46 @@ void ScenarioCanvasWidget::mouseMoveEvent(QMouseEvent* event) { } if (dragging_) { + if (!hoveredConnectionBlockId_.isEmpty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } dragCurrent_ = event->position(); update(); event->accept(); return; } if (selectionDragging_) { + if (!hoveredConnectionBlockId_.isEmpty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } selectionDragCurrent_ = event->position(); update(); event->accept(); return; } + + if (const auto bounds = collectBounds(); bounds.has_value()) { + const auto transform = currentTransform(*bounds); + const auto hoveredIndex = hoveredConnectionBlockIndex(layout_, connectionBlocks_, transform, currentFloorId_, event->position()); + if (!hoveredIndex.has_value()) { + if (!hoveredConnectionBlockId_.isEmpty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } + } else { + const auto& block = connectionBlocks_[*hoveredIndex]; + const auto tooltip = formatConnectionBlockTooltip(block); + if (!tooltip.isEmpty()) { + const auto hoveredId = QString::fromStdString(block.id.empty() ? block.connectionId : block.id); + if (hoveredId != hoveredConnectionBlockId_) { + hoveredConnectionBlockId_ = hoveredId; + QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); + } + } + } + } QWidget::mouseMoveEvent(event); } diff --git a/src/application/ScenarioCanvasWidget.h b/src/application/ScenarioCanvasWidget.h index 284d353..595d5b7 100644 --- a/src/application/ScenarioCanvasWidget.h +++ b/src/application/ScenarioCanvasWidget.h @@ -67,6 +67,7 @@ class ScenarioCanvasWidget : public QWidget { bool eventFilter(QObject* watched, QEvent* event) override; void keyPressEvent(QKeyEvent* event) override; void keyReleaseEvent(QKeyEvent* event) override; + void leaveEvent(QEvent* event) override; void mouseDoubleClickEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mousePressEvent(QMouseEvent* event) override; @@ -145,6 +146,7 @@ class ScenarioCanvasWidget : public QWidget { QSpinBox* groupCountSpinBox_{nullptr}; QLabel* groupDistributionLabel_{nullptr}; QComboBox* groupDistributionComboBox_{nullptr}; + QString hoveredConnectionBlockId_{}; std::function layoutElementActivatedHandler_{}; std::function crowdSelectionChangedHandler_{}; std::function&)> placementsChangedHandler_{}; diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index 0707ed6..6052196 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include namespace safecrowd::application { @@ -93,6 +94,65 @@ QColor densityHeatmapColor(double ratio, int alpha) { return QColor(220, 38, 38, alpha); } +QString formatScheduleTooltip(const safecrowd::domain::ConnectionBlockDraft& block) { + if (block.connectionId.empty()) { + return {}; + } + + QString text = QStringLiteral("Block schedule"); + if (block.intervals.empty()) { + text.append("\n- Always"); + return text; + } + + for (const auto& interval : block.intervals) { + const auto start = std::max(0.0, interval.startSeconds); + const auto end = std::max(start, interval.endSeconds); + text.append(QString("\n- %1s ~ %2s").arg(start, 0, 'f', 1).arg(end, 0, 'f', 1)); + } + return text; +} + +std::optional hoveredBlockedConnectionIndex( + const safecrowd::domain::FacilityLayout2D& layout, + const std::vector& blocks, + const LayoutCanvasTransform& transform, + const std::string& currentFloorId, + double elapsedSeconds, + const QPointF& screenPosition) { + constexpr double kHoverRadiusPixels = 14.0; + + std::optional closestIndex; + double closestDistanceSq = kHoverRadiusPixels * kHoverRadiusPixels; + + for (std::size_t index = 0; index < blocks.size(); ++index) { + const auto& block = blocks[index]; + if (!connectionShouldBeBlocked(block, elapsedSeconds)) { + continue; + } + const auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [&](const auto& connection) { + return connection.id == block.connectionId; + }); + if (it == layout.connections.end()) { + continue; + } + if (!matchesFloor(it->floorId, currentFloorId)) { + continue; + } + + const auto center = transform.map(connectionCenter(*it)); + const auto dx = center.x() - screenPosition.x(); + const auto dy = center.y() - screenPosition.y(); + const auto distanceSq = (dx * dx) + (dy * dy); + if (distanceSq <= closestDistanceSq) { + closestDistanceSq = distanceSq; + closestIndex = index; + } + } + + return closestIndex; +} + } // namespace SimulationCanvasWidget::SimulationCanvasWidget(safecrowd::domain::FacilityLayout2D layout, QWidget* parent) @@ -211,6 +271,12 @@ void SimulationCanvasWidget::keyReleaseEvent(QKeyEvent* event) { QWidget::keyReleaseEvent(event); } +void SimulationCanvasWidget::leaveEvent(QEvent* event) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + QWidget::leaveEvent(event); +} + void SimulationCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { camera_.reset(); @@ -228,6 +294,48 @@ void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { update(); return; } + + const auto bounds = collectBounds(); + if (!bounds.has_value()) { + if (!hoveredConnectionBlockId_.empty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } + QWidget::mouseMoveEvent(event); + return; + } + + const auto transform = currentTransform(*bounds); + const auto elapsedSeconds = std::max(0.0, frame_.elapsedSeconds); + const auto hoveredIndex = hoveredBlockedConnectionIndex( + layout_, + connectionBlocks_, + transform, + currentFloorId_, + elapsedSeconds, + event->position()); + + if (!hoveredIndex.has_value()) { + if (!hoveredConnectionBlockId_.empty()) { + hoveredConnectionBlockId_.clear(); + QToolTip::hideText(); + } + QWidget::mouseMoveEvent(event); + return; + } + + const auto& block = connectionBlocks_[*hoveredIndex]; + const auto tooltip = formatScheduleTooltip(block); + if (tooltip.isEmpty()) { + QWidget::mouseMoveEvent(event); + return; + } + + const auto hoveredId = block.id.empty() ? block.connectionId : block.id; + if (hoveredId != hoveredConnectionBlockId_) { + hoveredConnectionBlockId_ = hoveredId; + QToolTip::showText(event->globalPosition().toPoint(), tooltip, this); + } QWidget::mouseMoveEvent(event); } diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 21f143f..5f5fa64 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -51,6 +51,7 @@ class SimulationCanvasWidget : public QWidget { bool eventFilter(QObject* watched, QEvent* event) override; void keyPressEvent(QKeyEvent* event) override; void keyReleaseEvent(QKeyEvent* event) override; + void leaveEvent(QEvent* event) override; void mouseDoubleClickEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mousePressEvent(QMouseEvent* event) override; @@ -95,6 +96,8 @@ class SimulationCanvasWidget : public QWidget { double layoutCacheZoom_{0.0}; double layoutCacheDevicePixelRatio_{0.0}; bool layoutCacheValid_{false}; + + std::string hoveredConnectionBlockId_{}; }; } // namespace safecrowd::application