diff --git a/src/application/LayoutCanvasRendering.cpp b/src/application/LayoutCanvasRendering.cpp index b6fb1a9..f4e6fdf 100644 --- a/src/application/LayoutCanvasRendering.cpp +++ b/src/application/LayoutCanvasRendering.cpp @@ -85,7 +85,8 @@ bool LayoutCanvasCamera::handleKeyRelease(QKeyEvent* event) { } bool LayoutCanvasCamera::beginPan(QMouseEvent* event) { - if (event->button() != Qt::MiddleButton && !(event->button() == Qt::LeftButton && spacePressed_)) { + const auto primaryPan = event->button() == Qt::LeftButton && (spacePressed_ || primaryButtonPanEnabled_); + if (event->button() != Qt::MiddleButton && !primaryPan) { return false; } diff --git a/src/application/LayoutCanvasRendering.h b/src/application/LayoutCanvasRendering.h index fb10382..c18c9be 100644 --- a/src/application/LayoutCanvasRendering.h +++ b/src/application/LayoutCanvasRendering.h @@ -76,6 +76,10 @@ class LayoutCanvasCamera { panOffset_ = panOffset; } + void setPrimaryButtonPanEnabled(bool enabled) noexcept { + primaryButtonPanEnabled_ = enabled; + } + bool panning() const noexcept { return panning_; } @@ -87,6 +91,7 @@ class LayoutCanvasCamera { Qt::MouseButton panButton_{Qt::NoButton}; bool panning_{false}; bool spacePressed_{false}; + bool primaryButtonPanEnabled_{false}; }; void includeLayoutCanvasPoint(LayoutCanvasBounds& bounds, const safecrowd::domain::Point2D& point); diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index 06c615a..0513039 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -152,6 +152,11 @@ void MainWindow::saveCurrentProject() { QMessageBox::warning(this, "Save Project", errorMessage); return; } + } else if (auto* authoringWidget = dynamic_cast(centralWidget())) { + if (!ProjectPersistence::saveScenarioAuthoringState(currentProject_, authoringWidget->currentState(), &errorMessage)) { + QMessageBox::warning(this, "Save Project", errorMessage); + return; + } } currentProject_ = ProjectPersistence::loadProject(currentProject_.folderPath); @@ -197,6 +202,13 @@ void MainWindow::showLayoutReview(const ProjectMetadata& metadata, safecrowd::do showProjectNavigator(); }, [this](const safecrowd::domain::ImportResult& approvedImportResult) { + if (!currentProject_.isBuiltInDemo()) { + QString errorMessage; + if (!ProjectPersistence::saveProjectReview(currentProject_, approvedImportResult, &errorMessage)) { + QMessageBox::warning(this, "Approve Layout", errorMessage); + return; + } + } lastApprovedImportResult_ = approvedImportResult; showScenarioAuthoring(approvedImportResult); }, @@ -209,26 +221,47 @@ void MainWindow::showScenarioAuthoring(const safecrowd::domain::ImportResult& im return; } + ScenarioAuthoringWidget::InitialState initialState; + const bool hasSavedScenarioState = ProjectPersistence::loadScenarioAuthoringState( + currentProject_, + *importResult.layout, + &initialState); lastApprovedImportResult_ = importResult; + auto saveHandler = [this]() { + saveCurrentProject(); + }; + auto openProjectHandler = [this]() { + hasCurrentProject_ = false; + currentProject_ = {}; + showProjectNavigator(); + }; + auto backToLayoutReviewHandler = [this]() { + if (lastApprovedImportResult_.has_value()) { + showLayoutReview(currentProject_, *lastApprovedImportResult_); + } else { + showLayoutReview(currentProject_); + } + }; + + if (hasSavedScenarioState) { + setCentralWidget(new ScenarioAuthoringWidget( + currentProject_.name, + *importResult.layout, + std::move(initialState), + saveHandler, + openProjectHandler, + backToLayoutReviewHandler, + this)); + return; + } + setCentralWidget(new ScenarioAuthoringWidget( currentProject_.name, *importResult.layout, - [this]() { - saveCurrentProject(); - }, - [this]() { - hasCurrentProject_ = false; - currentProject_ = {}; - showProjectNavigator(); - }, - [this]() { - if (lastApprovedImportResult_.has_value()) { - showLayoutReview(currentProject_, *lastApprovedImportResult_); - } else { - showLayoutReview(currentProject_); - } - }, + saveHandler, + openProjectHandler, + backToLayoutReviewHandler, this)); } diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index ca9dbe8..6624671 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1,6 +1,8 @@ #include "application/ProjectPersistence.h" #include +#include +#include #include #include @@ -19,11 +21,13 @@ namespace { constexpr auto kProjectFileName = "safecrowd-project.json"; constexpr auto kLayoutFileName = "layout.dxf"; constexpr auto kReviewFileName = "layout-review.json"; +constexpr auto kScenarioAuthoringFileName = "scenario-authoring.json"; bool isProjectManagedEntry(const QString& fileName) { return fileName.compare(kProjectFileName, Qt::CaseInsensitive) == 0 || fileName.compare(kLayoutFileName, Qt::CaseInsensitive) == 0 - || fileName.compare(kReviewFileName, Qt::CaseInsensitive) == 0; + || fileName.compare(kReviewFileName, Qt::CaseInsensitive) == 0 + || fileName.compare(kScenarioAuthoringFileName, Qt::CaseInsensitive) == 0; } QString projectFilePath(const QString& folderPath) { @@ -34,6 +38,10 @@ QString reviewFilePath(const QString& folderPath) { return QDir(folderPath).filePath(kReviewFileName); } +QString scenarioAuthoringFilePath(const QString& folderPath) { + return QDir(folderPath).filePath(kScenarioAuthoringFileName); +} + QString recentProjectsPath() { const auto appData = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(appData); @@ -278,6 +286,14 @@ std::vector stringVectorFromJson(const QJsonArray& array) { return values; } +QJsonArray placementAreaToJson(const std::vector& area) { + return ringToJson(area); +} + +std::vector placementAreaFromJson(const QJsonArray& array) { + return ringFromJson(array); +} + QJsonObject provenanceToJson(const safecrowd::domain::ElementProvenance& provenance) { QJsonObject object; object["sourceIds"] = stringArray(provenance.sourceIds); @@ -476,6 +492,229 @@ void updateLiveValidationIssues(safecrowd::domain::ImportResult* importResult) { importResult->issues = std::move(issues); } +QJsonObject executionToJson(const safecrowd::domain::ExecutionConfig& execution) { + QJsonObject object; + object["timeLimitSeconds"] = execution.timeLimitSeconds; + object["sampleIntervalSeconds"] = execution.sampleIntervalSeconds; + object["repeatCount"] = static_cast(execution.repeatCount); + object["baseSeed"] = static_cast(execution.baseSeed); + object["recordOccupantHistory"] = execution.recordOccupantHistory; + return object; +} + +safecrowd::domain::ExecutionConfig executionFromJson(const QJsonObject& object) { + return { + .timeLimitSeconds = std::max(1.0, object.value("timeLimitSeconds").toDouble(600.0)), + .sampleIntervalSeconds = std::max(0.1, object.value("sampleIntervalSeconds").toDouble(1.0)), + .repeatCount = static_cast(std::max(1, object.value("repeatCount").toInt(1))), + .baseSeed = static_cast(object.value("baseSeed").toInt()), + .recordOccupantHistory = object.value("recordOccupantHistory").toBool(false), + }; +} + +safecrowd::domain::ScenarioRole scenarioRoleFromJson(const QJsonValue& value) { + switch (value.toInt(static_cast(safecrowd::domain::ScenarioRole::Alternative))) { + case static_cast(safecrowd::domain::ScenarioRole::Baseline): + return safecrowd::domain::ScenarioRole::Baseline; + case static_cast(safecrowd::domain::ScenarioRole::Recommended): + return safecrowd::domain::ScenarioRole::Recommended; + case static_cast(safecrowd::domain::ScenarioRole::Alternative): + default: + return safecrowd::domain::ScenarioRole::Alternative; + } +} + +QJsonObject eventToJson(const safecrowd::domain::OperationalEventDraft& event) { + QJsonObject object; + object["id"] = QString::fromStdString(event.id); + object["name"] = QString::fromStdString(event.name); + object["triggerSummary"] = QString::fromStdString(event.triggerSummary); + object["targetSummary"] = QString::fromStdString(event.targetSummary); + return object; +} + +safecrowd::domain::OperationalEventDraft eventFromJson(const QJsonObject& object) { + return { + .id = object.value("id").toString().toStdString(), + .name = object.value("name").toString().toStdString(), + .triggerSummary = object.value("triggerSummary").toString().toStdString(), + .targetSummary = object.value("targetSummary").toString().toStdString(), + }; +} + +QJsonArray eventsToJson(const std::vector& events) { + QJsonArray array; + for (const auto& event : events) { + array.append(eventToJson(event)); + } + return array; +} + +std::vector eventsFromJson(const QJsonArray& array) { + std::vector events; + events.reserve(array.size()); + for (const auto& value : array) { + events.push_back(eventFromJson(value.toObject())); + } + return events; +} + +QJsonObject placementToJson(const ScenarioCrowdPlacement& placement) { + QJsonObject object; + object["id"] = placement.id; + object["name"] = placement.name; + object["kind"] = static_cast(placement.kind); + object["zoneId"] = placement.zoneId; + object["area"] = placementAreaToJson(placement.area); + object["occupantCount"] = placement.occupantCount; + object["velocity"] = pointArray(placement.velocity); + return object; +} + +ScenarioCrowdPlacement placementFromJson(const QJsonObject& object) { + const auto kindValue = object.value("kind").toInt(static_cast(ScenarioCrowdPlacementKind::Individual)); + const auto kind = kindValue == static_cast(ScenarioCrowdPlacementKind::Group) + ? ScenarioCrowdPlacementKind::Group + : ScenarioCrowdPlacementKind::Individual; + + return { + .id = object.value("id").toString(), + .name = object.value("name").toString(), + .kind = kind, + .zoneId = object.value("zoneId").toString(), + .area = placementAreaFromJson(object.value("area").toArray()), + .occupantCount = std::max(1, object.value("occupantCount").toInt(1)), + .velocity = pointFromJson(object.value("velocity")), + }; +} + +QJsonArray placementsToJson(const std::vector& placements) { + QJsonArray array; + for (const auto& placement : placements) { + array.append(placementToJson(placement)); + } + return array; +} + +std::vector placementsFromJson(const QJsonArray& array) { + std::vector placements; + placements.reserve(array.size()); + for (const auto& value : array) { + placements.push_back(placementFromJson(value.toObject())); + } + return placements; +} + +std::unordered_set layoutZoneIds(const safecrowd::domain::FacilityLayout2D& layout) { + std::unordered_set zoneIds; + zoneIds.reserve(layout.zones.size()); + for (const auto& zone : layout.zones) { + zoneIds.insert(zone.id); + } + return zoneIds; +} + +void syncDraftFromScenarioState(ScenarioAuthoringWidget::ScenarioState* scenario) { + if (scenario == nullptr) { + return; + } + + scenario->draft.control.events = scenario->events; + scenario->draft.population.initialPlacements.clear(); + for (const auto& placement : scenario->crowdPlacements) { + safecrowd::domain::InitialPlacement2D initialPlacement; + initialPlacement.id = placement.id.toStdString(); + initialPlacement.zoneId = placement.zoneId.toStdString(); + initialPlacement.area.outline = placement.area; + initialPlacement.targetAgentCount = static_cast(placement.occupantCount); + initialPlacement.initialVelocity = placement.velocity; + scenario->draft.population.initialPlacements.push_back(std::move(initialPlacement)); + } +} + +void removePlacementsOutsideLayout( + const safecrowd::domain::FacilityLayout2D& layout, + ScenarioAuthoringWidget::ScenarioState* scenario) { + if (scenario == nullptr || scenario->crowdPlacements.empty()) { + return; + } + + const auto validZoneIds = layoutZoneIds(layout); + const auto oldSize = scenario->crowdPlacements.size(); + scenario->crowdPlacements.erase( + std::remove_if( + scenario->crowdPlacements.begin(), + scenario->crowdPlacements.end(), + [&validZoneIds](const ScenarioCrowdPlacement& placement) { + return !validZoneIds.contains(placement.zoneId.toStdString()); + }), + scenario->crowdPlacements.end()); + + if (scenario->crowdPlacements.size() != oldSize) { + scenario->stagedForRun = false; + syncDraftFromScenarioState(scenario); + } +} + +QJsonObject scenarioStateToJson(const ScenarioAuthoringWidget::ScenarioState& scenario) { + QJsonObject object; + object["scenarioId"] = QString::fromStdString(scenario.draft.scenarioId); + object["name"] = QString::fromStdString(scenario.draft.name); + object["role"] = static_cast(scenario.draft.role); + object["sourceTemplateId"] = QString::fromStdString(scenario.draft.sourceTemplateId); + object["variationDiffKeys"] = stringArray(scenario.draft.variationDiffKeys); + object["execution"] = executionToJson(scenario.draft.execution); + object["events"] = eventsToJson(scenario.events); + object["placements"] = placementsToJson(scenario.crowdPlacements); + object["startText"] = scenario.startText; + object["destinationText"] = scenario.destinationText; + object["baseScenarioId"] = scenario.baseScenarioId; + object["stagedForRun"] = scenario.stagedForRun; + return object; +} + +ScenarioAuthoringWidget::ScenarioState scenarioStateFromJson(const QJsonObject& object) { + ScenarioAuthoringWidget::ScenarioState scenario; + scenario.draft.scenarioId = object.value("scenarioId").toString().toStdString(); + scenario.draft.name = object.value("name").toString().toStdString(); + scenario.draft.role = scenarioRoleFromJson(object.value("role")); + scenario.draft.sourceTemplateId = object.value("sourceTemplateId").toString().toStdString(); + scenario.draft.variationDiffKeys = stringVectorFromJson(object.value("variationDiffKeys").toArray()); + scenario.draft.execution = executionFromJson(object.value("execution").toObject()); + scenario.events = eventsFromJson(object.value("events").toArray()); + scenario.crowdPlacements = placementsFromJson(object.value("placements").toArray()); + scenario.startText = object.value("startText").toString(); + scenario.destinationText = object.value("destinationText").toString(); + scenario.baseScenarioId = object.value("baseScenarioId").toString(); + scenario.stagedForRun = object.value("stagedForRun").toBool(false); + syncDraftFromScenarioState(&scenario); + return scenario; +} + +ScenarioAuthoringWidget::NavigationView navigationViewFromJson(const QJsonValue& value) { + switch (value.toInt(static_cast(ScenarioAuthoringWidget::NavigationView::Layout))) { + case static_cast(ScenarioAuthoringWidget::NavigationView::Crowd): + return ScenarioAuthoringWidget::NavigationView::Crowd; + case static_cast(ScenarioAuthoringWidget::NavigationView::Events): + return ScenarioAuthoringWidget::NavigationView::Events; + case static_cast(ScenarioAuthoringWidget::NavigationView::Layout): + default: + return ScenarioAuthoringWidget::NavigationView::Layout; + } +} + +ScenarioAuthoringWidget::RightPanelMode rightPanelModeFromJson(const QJsonValue& value) { + switch (value.toInt(static_cast(ScenarioAuthoringWidget::RightPanelMode::Scenario))) { + case static_cast(ScenarioAuthoringWidget::RightPanelMode::None): + return ScenarioAuthoringWidget::RightPanelMode::None; + case static_cast(ScenarioAuthoringWidget::RightPanelMode::Run): + return ScenarioAuthoringWidget::RightPanelMode::Run; + case static_cast(ScenarioAuthoringWidget::RightPanelMode::Scenario): + default: + return ScenarioAuthoringWidget::RightPanelMode::Scenario; + } +} + } // namespace QList ProjectPersistence::loadRecentProjects() { @@ -642,4 +881,64 @@ bool ProjectPersistence::saveProjectReview( return writeJsonDocument(reviewFilePath(metadata.folderPath), QJsonDocument(root), errorMessage); } +bool ProjectPersistence::loadScenarioAuthoringState( + const ProjectMetadata& metadata, + const safecrowd::domain::FacilityLayout2D& layout, + ScenarioAuthoringWidget::InitialState* state) { + if (metadata.isBuiltInDemo() || state == nullptr) { + return false; + } + + const auto document = readJsonDocument(scenarioAuthoringFilePath(metadata.folderPath)); + if (!document.isObject()) { + return false; + } + + const auto root = document.object(); + ScenarioAuthoringWidget::InitialState loaded; + loaded.currentScenarioIndex = root.value("currentScenarioIndex").toInt(-1); + loaded.navigationView = navigationViewFromJson(root.value("navigationView")); + loaded.rightPanelMode = rightPanelModeFromJson(root.value("rightPanelMode")); + + for (const auto& value : root.value("scenarios").toArray()) { + loaded.scenarios.push_back(scenarioStateFromJson(value.toObject())); + } + for (auto& scenario : loaded.scenarios) { + removePlacementsOutsideLayout(layout, &scenario); + } + + if (loaded.scenarios.empty()) { + loaded.currentScenarioIndex = -1; + } else if (loaded.currentScenarioIndex < 0 || loaded.currentScenarioIndex >= static_cast(loaded.scenarios.size())) { + loaded.currentScenarioIndex = 0; + } + + *state = std::move(loaded); + return true; +} + +bool ProjectPersistence::saveScenarioAuthoringState( + const ProjectMetadata& metadata, + const ScenarioAuthoringWidget::InitialState& state, + QString* errorMessage) { + if (metadata.isBuiltInDemo()) { + if (errorMessage != nullptr) { + *errorMessage = "Built-in demo projects do not need to be saved."; + } + return false; + } + + QJsonArray scenarios; + for (const auto& scenario : state.scenarios) { + scenarios.append(scenarioStateToJson(scenario)); + } + + QJsonObject root; + root["currentScenarioIndex"] = state.currentScenarioIndex; + root["navigationView"] = static_cast(state.navigationView); + root["rightPanelMode"] = static_cast(state.rightPanelMode); + root["scenarios"] = scenarios; + return writeJsonDocument(scenarioAuthoringFilePath(metadata.folderPath), QJsonDocument(root), errorMessage); +} + } // namespace safecrowd::application diff --git a/src/application/ProjectPersistence.h b/src/application/ProjectPersistence.h index de2c7d3..4406fa3 100644 --- a/src/application/ProjectPersistence.h +++ b/src/application/ProjectPersistence.h @@ -3,6 +3,8 @@ #include #include "application/ProjectMetadata.h" +#include "application/ScenarioAuthoringWidget.h" +#include "domain/FacilityLayout2D.h" #include "domain/ImportResult.h" namespace safecrowd::application { @@ -18,6 +20,14 @@ class ProjectPersistence { const ProjectMetadata& metadata, const safecrowd::domain::ImportResult& importResult, QString* errorMessage = nullptr); + static bool loadScenarioAuthoringState( + const ProjectMetadata& metadata, + const safecrowd::domain::FacilityLayout2D& layout, + ScenarioAuthoringWidget::InitialState* state); + static bool saveScenarioAuthoringState( + const ProjectMetadata& metadata, + const ScenarioAuthoringWidget::InitialState& state, + QString* errorMessage = nullptr); }; } // namespace safecrowd::application diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 6cd05cb..0720045 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -57,6 +57,48 @@ const safecrowd::domain::Zone2D* firstDestinationZone(const safecrowd::domain::F return layout.zones.empty() ? nullptr : &layout.zones.back(); } +struct EventPreset { + QString name{}; + QString triggerSummary{}; + QString targetSummary{}; +}; + +const std::vector& sprint1EventPresets() { + static const std::vector presets{ + { + .name = "Exit Closure", + .triggerSummary = "Operator command", + .targetSummary = "Primary exit route", + }, + { + .name = "Staged Release", + .triggerSummary = "After initial evacuation wave", + .targetSummary = "Selected start area occupants", + }, + }; + return presets; +} + +QString readinessListText(const QString& readyText, const QStringList& missingItems) { + if (missingItems.isEmpty()) { + return readyText; + } + + return QString("Missing before ready:\n- %1").arg(missingItems.join("\n- ")); +} + +QString eventSummary(const std::vector& events) { + if (events.empty()) { + return "none"; + } + + QStringList names; + for (const auto& event : events) { + names << QString::fromStdString(event.name); + } + return names.join(", "); +} + QIcon makeCrowdIcon(const QColor& color) { QPixmap pixmap(44, 44); pixmap.fill(Qt::transparent); @@ -168,14 +210,13 @@ std::vector buildCrowdTree(const ScenarioAuthoringWidget::Sc } placements.push_back({ - .label = QString("%1 - %2 - %3 %4") + .label = QString("%1 (%2)") .arg( - placement.name.isEmpty() ? placement.id : placement.name, - placement.zoneId) - .arg(placement.occupantCount) - .arg(placement.occupantCount == 1 ? "occupant" : "occupants"), + placement.name.isEmpty() ? placement.id : placement.name) + .arg(placement.occupantCount == 1 ? QString("1 occupant") : QString("%1 occupants").arg(placement.occupantCount)), .id = placement.id, - .detail = QString("Velocity: (%1, %2)") + .detail = QString("Zone: %1\nVelocity: (%2, %3)") + .arg(placement.zoneId) .arg(placement.velocity.x, 0, 'f', 2) .arg(placement.velocity.y, 0, 'f', 2), .children = group ? std::move(occupants) : std::vector{}, @@ -214,12 +255,14 @@ std::vector buildEventsTree(const ScenarioAuthoringWidget::S .detail = QString::fromStdString(event.targetSummary), .children = { { - .label = QString("Trigger - %1").arg(QString::fromStdString(event.triggerSummary)), + .label = "Trigger", .id = QString("%1/trigger").arg(eventId), + .detail = QString::fromStdString(event.triggerSummary), }, { - .label = QString("Target - %1").arg(QString::fromStdString(event.targetSummary)), + .label = "Target", .id = QString("%1/target").arg(eventId), + .detail = QString::fromStdString(event.targetSummary), }, }, .expanded = true, @@ -227,7 +270,7 @@ std::vector buildEventsTree(const ScenarioAuthoringWidget::S } return {{ - .label = QString("Events (%1)").arg(static_cast(scenario->events.size())), + .label = QString("Configured (%1)").arg(static_cast(scenario->events.size())), .children = std::move(events), .expanded = true, .selectable = false, @@ -236,15 +279,69 @@ std::vector buildEventsTree(const ScenarioAuthoringWidget::S QWidget* createEventsPanel( const ScenarioAuthoringWidget::ScenarioState* scenario, + std::function addEventHandler, const WorkspaceShell* shell, QWidget* parent) { - return new NavigationTreeWidget( - "Events", + auto* content = new QWidget(parent); + auto* layout = new QVBoxLayout(content); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(12); + layout->addWidget(shell != nullptr ? shell->createPanelHeader("Scenario Events", content, false) : createLabel("Scenario Events", content, ui::FontRole::Title)); + + auto* libraryHeader = createLabel("Configured Event Library", content, ui::FontRole::SectionTitle); + libraryHeader->setStyleSheet(ui::subtleTextStyleSheet()); + layout->addWidget(libraryHeader); + + const auto addPresetButton = [&](const EventPreset& preset) { + const auto name = preset.name; + const auto triggerSummary = preset.triggerSummary; + const auto targetSummary = preset.targetSummary; + auto* card = new QFrame(content); + card->setStyleSheet(ui::panelStyleSheet()); + card->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + auto* cardLayout = new QVBoxLayout(card); + cardLayout->setContentsMargins(12, 10, 12, 10); + cardLayout->setSpacing(6); + + auto* title = createLabel(name, card, ui::FontRole::SectionTitle); + cardLayout->addWidget(title); + + auto* detail = createLabel( + QString("Trigger: %1\nTarget: %2").arg(triggerSummary, targetSummary), + card, + ui::FontRole::Caption); + detail->setStyleSheet(ui::mutedTextStyleSheet()); + cardLayout->addWidget(detail); + + auto* button = new QPushButton("Add Event", card); + button->setFont(ui::font(ui::FontRole::Caption)); + button->setMinimumHeight(34); + button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + button->setStyleSheet(ui::secondaryButtonStyleSheet()); + button->setEnabled(scenario != nullptr); + button->setToolTip(QString("Add another %1 event").arg(name)); + auto handler = addEventHandler; + QObject::connect(button, &QPushButton::clicked, card, [=]() { + if (handler) { + handler(name, triggerSummary, targetSummary); + } + }); + cardLayout->addWidget(button); + layout->addWidget(card); + }; + + for (const auto& preset : sprint1EventPresets()) { + addPresetButton(preset); + } + + layout->addWidget(new NavigationTreeWidget( + "Configured Events", buildEventsTree(scenario), - "No operational events yet", + "No configured scenario events yet", {}, - parent, - shell != nullptr ? shell->createPanelHeader("Events", parent, false) : nullptr); + content, + createLabel("Configured Events", content, ui::FontRole::SectionTitle)), 1); + return content; } } // namespace @@ -316,16 +413,16 @@ void ScenarioAuthoringWidget::addEventDraft(const QString& name, const QString& return; } - const auto exists = std::any_of(scenario->events.begin(), scenario->events.end(), [&](const auto& event) { - return QString::fromStdString(event.name) == name; - }); - if (exists) { - return; + auto eventName = name; + for (int suffix = 2; std::any_of(scenario->events.begin(), scenario->events.end(), [&](const auto& event) { + return QString::fromStdString(event.name) == eventName; + }); ++suffix) { + eventName = QString("%1 %2").arg(name).arg(suffix); } scenario->events.push_back({ .id = QString("event-%1").arg(static_cast(scenario->events.size()) + 1).toStdString(), - .name = name.toStdString(), + .name = eventName.toStdString(), .triggerSummary = trigger.toStdString(), .targetSummary = target.toStdString(), }); @@ -420,13 +517,14 @@ void ScenarioAuthoringWidget::refreshInspector() { for (const auto& placement : scenario->crowdPlacements) { people += placement.occupantCount; } - scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6") + scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6\nStaged: %7") .arg( QString::fromStdString(scenario->draft.name), scenario->draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative") .arg(people) .arg(scenario->startText, scenario->destinationText) - .arg(static_cast(scenario->events.size()))); + .arg(static_cast(scenario->events.size())) + .arg(scenario->stagedForRun ? "Yes" : "No")); } } @@ -449,9 +547,23 @@ void ScenarioAuthoringWidget::refreshInspector() { if (newScenarioButton_ != nullptr) { newScenarioButton_->setText(hasScenario ? "New Scenario from Current" : "New Scenario"); } + + const auto readiness = readinessStatus(); + if (readinessLabel_ != nullptr) { + if (rightPanelMode_ == RightPanelMode::Run) { + readinessLabel_->setText(readinessListText("Ready to run the staged baseline scenario.", readiness.missingRunItems)); + } else { + readinessLabel_->setText(readinessListText("Ready to stage this scenario.", readiness.missingStageItems)); + } + } if (stageScenarioButton_ != nullptr) { - stageScenarioButton_->setEnabled(hasScenario); + stageScenarioButton_->setEnabled(readiness.missingStageItems.isEmpty()); stageScenarioButton_->setText(hasScenario && scenario->stagedForRun ? "Staged for Run" : "Stage Scenario"); + stageScenarioButton_->setToolTip(readinessListText("This scenario can be staged for run.", readiness.missingStageItems)); + } + if (executeRunButton_ != nullptr) { + executeRunButton_->setEnabled(readiness.missingRunItems.isEmpty()); + executeRunButton_->setToolTip(readinessListText("Run the staged baseline scenario.", readiness.missingRunItems)); } } @@ -493,13 +605,20 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { shell_)); return; } - shell_->setNavigationPanel(createEventsPanel(currentScenario(), shell_, shell_)); + shell_->setNavigationPanel(createEventsPanel( + currentScenario(), + [this](const QString& name, const QString& trigger, const QString& target) { + addEventDraft(name, trigger, target); + }, + shell_, + shell_)); } void ScenarioAuthoringWidget::refreshRightPanel() { scenarioSwitcher_ = nullptr; scenarioSummaryLabel_ = nullptr; changesLabel_ = nullptr; + readinessLabel_ = nullptr; newScenarioButton_ = nullptr; stageScenarioButton_ = nullptr; stagedScenariosLabel_ = nullptr; @@ -537,6 +656,14 @@ void ScenarioAuthoringWidget::refreshScenarioSwitcher() { } void ScenarioAuthoringWidget::runFirstStagedBaselineScenario() { + const auto readiness = readinessStatus(); + if (!readiness.missingRunItems.isEmpty()) { + if (readinessLabel_ != nullptr) { + readinessLabel_->setText(readinessListText("Ready to run the staged baseline scenario.", readiness.missingRunItems)); + } + return; + } + const auto* scenario = firstStagedBaselineScenario(); if (scenario == nullptr) { if (stagedScenariosLabel_ != nullptr) { @@ -558,6 +685,13 @@ void ScenarioAuthoringWidget::runFirstStagedBaselineScenario() { saveProjectHandler_, openProjectHandler_, backToLayoutReviewHandler_, + [this](bool showRunPanel) { + returnFromRun(showRunPanel); + }, + [this]() { + returnFromRun(true); + runFirstStagedBaselineScenario(); + }, this); rootLayout->replaceWidget(shell_, runWidget); shell_->hide(); @@ -576,7 +710,43 @@ void ScenarioAuthoringWidget::setRightPanelMode(RightPanelMode mode) { refreshRightPanel(); } +void ScenarioAuthoringWidget::returnFromRun(bool showRunPanel) { + auto* rootLayout = qobject_cast(layout()); + if (rootLayout == nullptr) { + return; + } + + while (auto* item = rootLayout->takeAt(0)) { + if (auto* widget = item->widget()) { + widget->hide(); + widget->deleteLater(); + } + delete item; + } + + rightPanelMode_ = showRunPanel ? RightPanelMode::Run : RightPanelMode::Scenario; + shell_ = new WorkspaceShell(this); + shell_->setTools({"Project"}); + shell_->setSaveProjectHandler(saveProjectHandler_); + shell_->setOpenProjectHandler(openProjectHandler_); + shell_->setBackHandler(backToLayoutReviewHandler_); + shell_->setTopBarTrailingWidget(createTopBarTogglePanel()); + refreshRightPanel(); + rootLayout->addWidget(shell_); + refreshNavigationPanel(); + refreshCanvas(); + refreshInspector(); +} + void ScenarioAuthoringWidget::stageCurrentScenario() { + const auto readiness = readinessStatus(); + if (!readiness.missingStageItems.isEmpty()) { + if (readinessLabel_ != nullptr) { + readinessLabel_->setText(readinessListText("Ready to stage this scenario.", readiness.missingStageItems)); + } + return; + } + auto* scenario = currentScenario(); if (scenario == nullptr) { return; @@ -625,7 +795,7 @@ void ScenarioAuthoringWidget::showEmptyCanvas() { auto* title = createLabel("Create a scenario", canvas, ui::FontRole::Title); title->setAlignment(Qt::AlignCenter); layout->addWidget(title); - auto* detail = createLabel("Name the first scenario to start authoring Layout, Crowd, and Events settings.", canvas); + auto* detail = createLabel("Name the first scenario to start authoring Layout, Crowd, and Scenario Events settings.", canvas); detail->setAlignment(Qt::AlignCenter); detail->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(detail); @@ -678,30 +848,41 @@ QWidget* ScenarioAuthoringWidget::createRunPanel() { const auto stagedCount = std::count_if(scenarios_.begin(), scenarios_.end(), [](const auto& scenario) { return scenario.stagedForRun; }); - if (stagedCount == 0) { - lines << "No staged scenarios"; + const auto stagedBaselineCount = std::count_if(scenarios_.begin(), scenarios_.end(), [](const auto& scenario) { + return scenario.stagedForRun && scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline; + }); + if (stagedBaselineCount == 0) { + lines << "No staged baseline scenario"; } else { - lines << "Staged scenarios"; + lines << "Staged baseline for Sprint 1 run"; for (const auto& scenario : scenarios_) { - if (!scenario.stagedForRun) { + if (!scenario.stagedForRun || scenario.draft.role != safecrowd::domain::ScenarioRole::Baseline) { continue; } - const auto role = scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative"; - lines << QString("- %1 (%2)").arg(QString::fromStdString(scenario.draft.name), role); + lines << QString("- %1\n Configured events: %2") + .arg(QString::fromStdString(scenario.draft.name)) + .arg(eventSummary(scenario.events)); } } + if (stagedCount > stagedBaselineCount) { + lines << "Staged alternatives remain authored but are not run in Sprint 1."; + } stagedScenariosLabel_->setText(lines.join('\n')); layout->addWidget(stagedScenariosLabel_); + + readinessLabel_ = createLabel("", panel); + readinessLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + layout->addWidget(readinessLabel_); layout->addStretch(1); - executeRunButton_ = new QPushButton("Run Staged Scenarios", panel); + executeRunButton_ = new QPushButton("Run Staged Baseline", panel); executeRunButton_->setFont(ui::font(ui::FontRole::Body)); executeRunButton_->setStyleSheet(ui::primaryButtonStyleSheet()); - executeRunButton_->setEnabled(stagedCount > 0); layout->addWidget(executeRunButton_); connect(executeRunButton_, &QPushButton::clicked, this, [this]() { runFirstStagedBaselineScenario(); }); + refreshInspector(); return panel; } @@ -731,6 +912,10 @@ QWidget* ScenarioAuthoringWidget::createScenarioPanel() { changesLabel_ = createLabel("", inspector); changesLabel_->setStyleSheet(ui::mutedTextStyleSheet()); inspectorLayout->addWidget(changesLabel_); + + readinessLabel_ = createLabel("", inspector); + readinessLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + inspectorLayout->addWidget(readinessLabel_); inspectorLayout->addStretch(1); stageScenarioButton_ = new QPushButton("Stage Scenario", inspector); @@ -791,6 +976,40 @@ QWidget* ScenarioAuthoringWidget::createTopBarTogglePanel() { return panel; } +ScenarioAuthoringWidget::ReadinessStatus ScenarioAuthoringWidget::readinessStatus() const { + ReadinessStatus status; + const auto* scenario = currentScenario(); + const auto* stagedBaseline = firstStagedBaselineScenario(); + status.hasCurrentScenario = scenario != nullptr; + status.hasCurrentPopulation = scenario != nullptr && !scenario->draft.population.initialPlacements.empty(); + status.hasDestinationZone = firstDestinationZone(layout_) != nullptr; + status.hasStagedBaselineScenario = stagedBaseline != nullptr; + status.hasRunnableBaselinePopulation = stagedBaseline != nullptr + && !stagedBaseline->draft.population.initialPlacements.empty(); + + if (!status.hasCurrentScenario) { + status.missingStageItems << "Create or select a scenario."; + } + if (!status.hasCurrentPopulation) { + status.missingStageItems << "Add at least one population placement on the Crowd canvas."; + } + if (!status.hasDestinationZone) { + status.missingStageItems << "Approve a layout with an Exit zone or another destination zone."; + } + + if (!status.hasStagedBaselineScenario) { + status.missingRunItems << "Stage a baseline scenario for run."; + } + if (status.hasStagedBaselineScenario && !status.hasRunnableBaselinePopulation) { + status.missingRunItems << "Add at least one population placement to the staged baseline scenario."; + } + if (!status.hasDestinationZone) { + status.missingRunItems << "Approve a layout with an Exit zone or another destination zone."; + } + + return status; +} + ScenarioAuthoringWidget::ScenarioState* ScenarioAuthoringWidget::currentScenario() { if (currentScenarioIndex_ < 0 || currentScenarioIndex_ >= static_cast(scenarios_.size())) { return nullptr; @@ -812,4 +1031,13 @@ const ScenarioAuthoringWidget::ScenarioState* ScenarioAuthoringWidget::firstStag return it == scenarios_.end() ? nullptr : &(*it); } +ScenarioAuthoringWidget::InitialState ScenarioAuthoringWidget::currentState() const { + return { + .scenarios = scenarios_, + .currentScenarioIndex = currentScenarioIndex_, + .navigationView = navigationView_, + .rightPanelMode = rightPanelMode_, + }; +} + } // namespace safecrowd::application diff --git a/src/application/ScenarioAuthoringWidget.h b/src/application/ScenarioAuthoringWidget.h index f23a473..0a67332 100644 --- a/src/application/ScenarioAuthoringWidget.h +++ b/src/application/ScenarioAuthoringWidget.h @@ -4,6 +4,7 @@ #include #include +#include #include #include "application/ScenarioCanvasWidget.h" @@ -66,6 +67,8 @@ class ScenarioAuthoringWidget : public QWidget { std::function backToLayoutReviewHandler, QWidget* parent = nullptr); + [[nodiscard]] InitialState currentState() const; + private: void initializeUi(bool promptForScenario); void addEventDraft(const QString& name, const QString& trigger, const QString& target); @@ -77,6 +80,7 @@ class ScenarioAuthoringWidget : public QWidget { void refreshNavigationPanel(); void refreshRightPanel(); void refreshScenarioSwitcher(); + void returnFromRun(bool showRunPanel); void runFirstStagedBaselineScenario(); void setRightPanelMode(RightPanelMode mode); void stageCurrentScenario(); @@ -86,6 +90,16 @@ class ScenarioAuthoringWidget : public QWidget { QWidget* createRunPanel(); QWidget* createScenarioPanel(); QWidget* createTopBarTogglePanel(); + struct ReadinessStatus { + bool hasCurrentScenario{false}; + bool hasCurrentPopulation{false}; + bool hasDestinationZone{false}; + bool hasStagedBaselineScenario{false}; + bool hasRunnableBaselinePopulation{false}; + QStringList missingStageItems{}; + QStringList missingRunItems{}; + }; + [[nodiscard]] ReadinessStatus readinessStatus() const; ScenarioState* currentScenario(); const ScenarioState* currentScenario() const; const ScenarioState* firstStagedBaselineScenario() const; @@ -106,6 +120,7 @@ class ScenarioAuthoringWidget : public QWidget { QComboBox* scenarioSwitcher_{nullptr}; QLabel* scenarioSummaryLabel_{nullptr}; QLabel* changesLabel_{nullptr}; + QLabel* readinessLabel_{nullptr}; QLabel* stagedScenariosLabel_{nullptr}; QPushButton* newScenarioButton_{nullptr}; QPushButton* stageScenarioButton_{nullptr}; diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index c1e44c9..a354d13 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "application/ScenarioAuthoringWidget.h" @@ -37,6 +38,61 @@ QString formatOptionalSeconds(const std::optional& seconds) { return seconds.has_value() ? QString("%1 sec").arg(*seconds, 0, 'f', 1) : QString("Pending"); } +QString zoneLabel(const safecrowd::domain::Zone2D& zone) { + const auto id = QString::fromStdString(zone.id); + const auto label = QString::fromStdString(zone.label); + return label.isEmpty() ? id : QString("%1 - %2").arg(label, id); +} + +const safecrowd::domain::Zone2D* firstStartZone(const safecrowd::domain::FacilityLayout2D& layout) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.kind == safecrowd::domain::ZoneKind::Room || zone.kind == safecrowd::domain::ZoneKind::Unknown; + }); + return it == layout.zones.end() ? nullptr : &(*it); +} + +const safecrowd::domain::Zone2D* firstDestinationZone(const safecrowd::domain::FacilityLayout2D& layout) { + const auto exitIt = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.kind == safecrowd::domain::ZoneKind::Exit; + }); + if (exitIt != layout.zones.end()) { + return &(*exitIt); + } + return layout.zones.empty() ? nullptr : &layout.zones.back(); +} + +ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( + const safecrowd::domain::ScenarioDraft& scenario, + const safecrowd::domain::FacilityLayout2D& layout) { + ScenarioAuthoringWidget::ScenarioState state; + state.draft = scenario; + state.events = scenario.control.events; + state.stagedForRun = true; + + if (const auto* startZone = firstStartZone(layout); startZone != nullptr) { + state.startText = zoneLabel(*startZone); + } + if (const auto* destinationZone = firstDestinationZone(layout); destinationZone != nullptr) { + state.destinationText = zoneLabel(*destinationZone); + } + + for (const auto& placement : scenario.population.initialPlacements) { + ScenarioCrowdPlacement uiPlacement; + uiPlacement.id = QString::fromStdString(placement.id); + uiPlacement.name = uiPlacement.id; + uiPlacement.kind = (placement.targetAgentCount <= 1 && placement.area.outline.size() <= 1) + ? ScenarioCrowdPlacementKind::Individual + : ScenarioCrowdPlacementKind::Group; + uiPlacement.zoneId = QString::fromStdString(placement.zoneId); + uiPlacement.area = placement.area.outline; + uiPlacement.occupantCount = static_cast(placement.targetAgentCount); + uiPlacement.velocity = placement.initialVelocity; + state.crowdPlacements.push_back(std::move(uiPlacement)); + } + + return state; +} + class EvacuationProgressWidget final : public QWidget { public: explicit EvacuationProgressWidget( @@ -159,6 +215,28 @@ QFrame* createMetricCard(const QString& title, const QString& value, QWidget* pa return card; } +bool allAgentsEvacuated(const safecrowd::domain::SimulationFrame& frame) { + return frame.totalAgentCount > 0 && frame.evacuatedAgentCount >= frame.totalAgentCount; +} + +QString completionOutcome(const safecrowd::domain::SimulationFrame& frame) { + if (!frame.complete) { + return "Stopped before completion"; + } + return allAgentsEvacuated(frame) ? "Evacuation complete" : "Time limit reached"; +} + +QString bottleneckSummary(const safecrowd::domain::ScenarioRiskSnapshot& risk) { + if (risk.bottlenecks.empty()) { + return "None"; + } + const auto& bottleneck = risk.bottlenecks.front(); + return QString("%1\n%2 nearby, %3 stalled") + .arg(QString::fromStdString(bottleneck.label)) + .arg(static_cast(bottleneck.nearbyAgentCount)) + .arg(static_cast(bottleneck.stalledAgentCount)); +} + QLabel* createReportSectionHeader(const QString& text, QWidget* parent) { auto* label = createLabel(text, parent, ui::FontRole::SectionTitle); label->setStyleSheet(ui::mutedTextStyleSheet()); @@ -171,18 +249,18 @@ QPushButton* createBottleneckRowButton( QWidget* parent) { const auto label = QString::fromStdString(bottleneck.label); const auto id = QString::fromStdString(bottleneck.connectionId); - const auto idLine = (!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{}; auto* button = new QPushButton( - QString("%1. %2%3\n%4 nearby, %5 stalled") + QString("Bottleneck %1\n%2 nearby, %3 stalled") .arg(static_cast(index + 1)) - .arg(label, idLine) .arg(static_cast(bottleneck.nearbyAgentCount)) .arg(static_cast(bottleneck.stalledAgentCount)), parent); button->setFont(ui::font(ui::FontRole::Body)); button->setCursor(Qt::PointingHandCursor); button->setStyleSheet(ui::ghostRowStyleSheet()); - button->setToolTip(QString("%1\nClick to focus this bottleneck on the canvas.") + button->setToolTip(QString("%1%2\n\n%3\nClick to focus this bottleneck on the canvas.") + .arg(label) + .arg((!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{}) .arg(safecrowd::domain::scenarioBottleneckDefinition())); return button; } @@ -235,59 +313,18 @@ QWidget* createHotspotLegend(QWidget* parent) { return legend; } -QString zoneLabel(const safecrowd::domain::Zone2D& zone) { - const auto id = QString::fromStdString(zone.id); - const auto label = QString::fromStdString(zone.label); - return label.isEmpty() ? id : QString("%1 - %2").arg(label, id); -} - -const safecrowd::domain::Zone2D* firstStartZone(const safecrowd::domain::FacilityLayout2D& layout) { - const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { - return zone.kind == safecrowd::domain::ZoneKind::Room || zone.kind == safecrowd::domain::ZoneKind::Unknown; - }); - return it == layout.zones.end() ? nullptr : &(*it); -} - -const safecrowd::domain::Zone2D* firstDestinationZone(const safecrowd::domain::FacilityLayout2D& layout) { - const auto exitIt = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { - return zone.kind == safecrowd::domain::ZoneKind::Exit; - }); - if (exitIt != layout.zones.end()) { - return &(*exitIt); +QString configuredEventSummary(const safecrowd::domain::ScenarioDraft& scenario) { + if (scenario.control.events.empty()) { + return "None"; } - return layout.zones.empty() ? nullptr : &layout.zones.back(); -} - -ScenarioAuthoringWidget::ScenarioState scenarioStateFromDraft( - const safecrowd::domain::ScenarioDraft& scenario, - const safecrowd::domain::FacilityLayout2D& layout) { - ScenarioAuthoringWidget::ScenarioState state; - state.draft = scenario; - state.events = scenario.control.events; - state.stagedForRun = true; - if (const auto* startZone = firstStartZone(layout); startZone != nullptr) { - state.startText = zoneLabel(*startZone); - } - if (const auto* destinationZone = firstDestinationZone(layout); destinationZone != nullptr) { - state.destinationText = zoneLabel(*destinationZone); - } - - for (const auto& placement : scenario.population.initialPlacements) { - ScenarioCrowdPlacement uiPlacement; - uiPlacement.id = QString::fromStdString(placement.id); - uiPlacement.name = uiPlacement.id; - uiPlacement.kind = (placement.targetAgentCount <= 1 && placement.area.outline.size() <= 1) - ? ScenarioCrowdPlacementKind::Individual - : ScenarioCrowdPlacementKind::Group; - uiPlacement.zoneId = QString::fromStdString(placement.zoneId); - uiPlacement.area = placement.area.outline; - uiPlacement.occupantCount = static_cast(placement.targetAgentCount); - uiPlacement.velocity = placement.initialVelocity; - state.crowdPlacements.push_back(std::move(uiPlacement)); + QStringList names; + for (const auto& event : scenario.control.events) { + names << QString::fromStdString(event.name); } - - return state; + return QString("%1 configured\n%2") + .arg(static_cast(scenario.control.events.size())) + .arg(names.join(", ")); } QWidget* createResultPanel( @@ -304,38 +341,44 @@ QWidget* createResultPanel( layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - layout->addWidget(shell != nullptr ? shell->createPanelHeader("Results", panel) : createLabel("Results", panel, ui::FontRole::Title)); - auto* scenarioLabel = createLabel(QString("Scenario: %1").arg(QString::fromStdString(scenario.name)), panel); + layout->addWidget(shell != nullptr ? shell->createPanelHeader("Baseline Result", panel) : createLabel("Baseline Result", panel, ui::FontRole::Title)); + auto* scenarioLabel = createLabel(QString("Staged baseline: %1").arg(QString::fromStdString(scenario.name)), panel); scenarioLabel->setStyleSheet(ui::mutedTextStyleSheet()); layout->addWidget(scenarioLabel); + auto* outcomeLabel = createLabel(QString("Outcome: %1").arg(completionOutcome(frame)), panel); + outcomeLabel->setStyleSheet(ui::mutedTextStyleSheet()); + layout->addWidget(outcomeLabel); auto* metricsGrid = new QGridLayout(); metricsGrid->setContentsMargins(0, 0, 0, 0); metricsGrid->setSpacing(8); const auto total = static_cast(frame.totalAgentCount); const auto evacuated = static_cast(frame.evacuatedAgentCount); - metricsGrid->addWidget(createMetricCard("Evacuated", QString("%1 / %2").arg(evacuated).arg(total), panel), 0, 0); - metricsGrid->addWidget(createMetricCard("Time", QString("%1 sec").arg(frame.elapsedSeconds, 0, 'f', 1), panel), 0, 1); + const auto remaining = std::max(0, total - evacuated); + const auto active = static_cast(frame.agents.size()); + metricsGrid->addWidget(createMetricCard("Total", QString::number(total), panel), 0, 0); + metricsGrid->addWidget(createMetricCard("Evacuated", QString("%1 / %2").arg(evacuated).arg(total), panel), 0, 1); + metricsGrid->addWidget(createMetricCard("Remaining", QString("%1 / %2").arg(remaining).arg(total), panel), 1, 0); metricsGrid->addWidget(createMetricCard( - "Risk", - safecrowd::domain::scenarioRiskLevelLabel(risk.completionRisk), - panel, - safecrowd::domain::scenarioRiskDefinition()), 1, 0); - metricsGrid->addWidget(createMetricCard( - "Stalled", - QString::number(static_cast(risk.stalledAgentCount)), - panel, - safecrowd::domain::scenarioStalledDefinition()), 1, 1); + "Elapsed / Time limit", + QString("%1 / %2 sec") + .arg(frame.elapsedSeconds, 0, 'f', 1) + .arg(scenario.execution.timeLimitSeconds, 0, 'f', 0), + panel), 1, 1); + metricsGrid->addWidget(createMetricCard("Active", QString::number(active), panel), 2, 0); + metricsGrid->addWidget(createMetricCard("Configured Events", configuredEventSummary(scenario), panel), 2, 1); + metricsGrid->addWidget(createMetricCard("Completion Risk", safecrowd::domain::scenarioRiskLevelLabel(risk.completionRisk), panel), 3, 0); + metricsGrid->addWidget(createMetricCard("Stalled", QString::number(static_cast(risk.stalledAgentCount)), panel), 3, 1); metricsGrid->addWidget(createMetricCard( "T90", formatOptionalSeconds(artifacts.timingSummary.t90Seconds), panel, - "Time at which 90% of occupants completed evacuation."), 2, 0); + "Time at which 90% of occupants completed evacuation."), 4, 0); metricsGrid->addWidget(createMetricCard( "T95", formatOptionalSeconds(artifacts.timingSummary.t95Seconds), panel, - "Time at which 95% of occupants completed evacuation."), 2, 1); + "Time at which 95% of occupants completed evacuation."), 4, 1); layout->addLayout(metricsGrid); layout->addStretch(1); @@ -470,6 +513,8 @@ ScenarioResultWidget::ScenarioResultWidget( std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler, + std::function rerunScenarioHandler, QWidget* parent) : QWidget(parent), projectName_(std::move(projectName)), @@ -480,7 +525,9 @@ ScenarioResultWidget::ScenarioResultWidget( artifacts_(std::move(artifacts)), saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), - backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)), + returnToAuthoringHandler_(std::move(returnToAuthoringHandler)), + rerunScenarioHandler_(std::move(rerunScenarioHandler)) { auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -537,6 +584,11 @@ ScenarioResultWidget::ScenarioResultWidget( } void ScenarioResultWidget::rerunScenario() { + if (rerunScenarioHandler_) { + rerunScenarioHandler_(); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; @@ -549,6 +601,8 @@ void ScenarioResultWidget::rerunScenario() { saveProjectHandler_, openProjectHandler_, backToLayoutReviewHandler_, + returnToAuthoringHandler_, + rerunScenarioHandler_, this); rootLayout->replaceWidget(shell_, runWidget); @@ -558,6 +612,11 @@ void ScenarioResultWidget::rerunScenario() { } void ScenarioResultWidget::navigateToAuthoring(bool showRunPanel) { + if (returnToAuthoringHandler_) { + returnToAuthoringHandler_(showRunPanel); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; diff --git a/src/application/ScenarioResultWidget.h b/src/application/ScenarioResultWidget.h index 2871fc7..3e3b2b6 100644 --- a/src/application/ScenarioResultWidget.h +++ b/src/application/ScenarioResultWidget.h @@ -27,6 +27,8 @@ class ScenarioResultWidget : public QWidget { std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler = {}, + std::function rerunScenarioHandler = {}, QWidget* parent = nullptr); private: @@ -42,6 +44,8 @@ class ScenarioResultWidget : public QWidget { std::function saveProjectHandler_{}; std::function openProjectHandler_{}; std::function backToLayoutReviewHandler_{}; + std::function returnToAuthoringHandler_{}; + std::function rerunScenarioHandler_{}; WorkspaceShell* shell_{nullptr}; }; diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index df50b54..2d5b6d1 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -26,6 +27,54 @@ namespace { constexpr double kSimulationDeltaSeconds = 1.0 / 30.0; +bool allAgentsEvacuated(const safecrowd::domain::SimulationFrame& frame) { + return frame.totalAgentCount > 0 && frame.evacuatedAgentCount >= frame.totalAgentCount; +} + +QString completionOutcome(const safecrowd::domain::SimulationFrame& frame) { + if (!frame.complete) { + return "In progress"; + } + return allAgentsEvacuated(frame) ? "Evacuation complete" : "Time limit reached"; +} + +QString runStatusText(const safecrowd::domain::SimulationFrame& frame, bool paused) { + if (frame.complete) { + return completionOutcome(frame); + } + if (paused && frame.elapsedSeconds <= 0.0 && frame.evacuatedAgentCount == 0) { + return "Reset to start"; + } + return paused ? "Paused" : "Running"; +} + +QString hotspotSummary(const safecrowd::domain::ScenarioRiskSnapshot& risk) { + if (risk.hotspots.empty()) { + return "Hotspots: 0"; + } + + const auto& hotspot = risk.hotspots.front(); + return QString("Hotspots: %1\nWorst: %2 agents at (%3, %4)") + .arg(static_cast(risk.hotspots.size())) + .arg(static_cast(hotspot.agentCount)) + .arg(hotspot.center.x, 0, 'f', 1) + .arg(hotspot.center.y, 0, 'f', 1); +} + +QString configuredEventSummary(const safecrowd::domain::ScenarioDraft& scenario) { + if (scenario.control.events.empty()) { + return "Configured Events: none"; + } + + QStringList names; + for (const auto& event : scenario.control.events) { + names << QString::fromStdString(event.name); + } + return QString("Configured Events: %1\n%2") + .arg(static_cast(scenario.control.events.size())) + .arg(names.join(", ")); +} + enum class TransportIconKind { Play, Pause, @@ -169,6 +218,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler, + std::function rerunScenarioHandler, QWidget* parent) : QWidget(parent), projectName_(projectName), @@ -177,7 +228,9 @@ ScenarioRunWidget::ScenarioRunWidget( runner_(layout_, scenario_), saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), - backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)), + returnToAuthoringHandler_(std::move(returnToAuthoringHandler)), + rerunScenarioHandler_(std::move(rerunScenarioHandler)) { auto* rootLayout = new QVBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setSpacing(0); @@ -223,7 +276,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(12); - layout->addWidget(shell_ != nullptr ? shell_->createPanelHeader("Run", panel) : createLabel("Run", panel, ui::FontRole::Title)); + layout->addWidget(shell_ != nullptr ? shell_->createPanelHeader("Baseline Run", panel) : createLabel("Baseline Run", panel, ui::FontRole::Title)); scenarioLabel_ = createLabel("", panel); scenarioLabel_->setStyleSheet(ui::mutedTextStyleSheet()); statusLabel_ = createLabel("", panel); @@ -233,6 +286,8 @@ QWidget* ScenarioRunWidget::createRunPanel() { timeProgressBar_ = createProgressBar("Time progress against the scenario time limit.", panel); agentCountLabel_ = createLabel("", panel); agentCountLabel_->setStyleSheet(ui::mutedTextStyleSheet()); + eventLabel_ = createLabel("", panel); + eventLabel_->setStyleSheet(ui::mutedTextStyleSheet()); evacuationProgressBar_ = createProgressBar("Evacuation progress based on evacuated agents divided by total agents.", panel); riskLabel_ = createLabel("", panel); riskLabel_->setStyleSheet(ui::mutedTextStyleSheet()); @@ -250,6 +305,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { layout->addWidget(elapsedLabel_); layout->addWidget(timeProgressBar_); layout->addWidget(agentCountLabel_); + layout->addWidget(eventLabel_); layout->addWidget(evacuationProgressBar_); layout->addWidget(riskLabel_); layout->addWidget(congestionLabel_); @@ -259,7 +315,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { transportLayout->setContentsMargins(0, 0, 0, 0); transportLayout->setSpacing(8); pauseButton_ = createIconButton(TransportIconKind::Pause, "Pause simulation", panel); - stopButton_ = createIconButton(TransportIconKind::Stop, "Stop and reset run", panel); + stopButton_ = createIconButton(TransportIconKind::Stop, "Reset run to start; no result is created", panel); transportLayout->addWidget(pauseButton_); transportLayout->addWidget(stopButton_); transportLayout->addStretch(1); @@ -271,6 +327,7 @@ QWidget* ScenarioRunWidget::createRunPanel() { resultButton_->setFont(ui::font(ui::FontRole::Body)); resultButton_->setStyleSheet(ui::primaryButtonStyleSheet()); resultButton_->setEnabled(false); + resultButton_->setToolTip("Available after evacuation completes or the time limit is reached"); layout->addWidget(resultButton_); connect(pauseButton_, &QPushButton::clicked, this, [this]() { @@ -291,6 +348,11 @@ void ScenarioRunWidget::returnToAuthoring() { timer_->stop(); } + if (returnToAuthoringHandler_) { + returnToAuthoringHandler_(true); + return; + } + auto* rootLayout = qobject_cast(layout()); if (rootLayout == nullptr || shell_ == nullptr) { return; @@ -321,10 +383,10 @@ void ScenarioRunWidget::returnToAuthoring() { void ScenarioRunWidget::refreshStatus() { const auto& frame = runner_.frame(); if (scenarioLabel_ != nullptr) { - scenarioLabel_->setText(QString("Scenario: %1").arg(QString::fromStdString(scenario_.name))); + scenarioLabel_->setText(QString("Staged baseline: %1").arg(QString::fromStdString(scenario_.name))); } if (statusLabel_ != nullptr) { - statusLabel_->setText(QString("Status: %1").arg(frame.complete ? "Complete" : paused_ ? "Paused" : "Running")); + statusLabel_->setText(QString("Status: %1").arg(runStatusText(frame, paused_))); } if (elapsedLabel_ != nullptr) { elapsedLabel_->setText(QString("Elapsed: %1 / %2 sec") @@ -340,6 +402,9 @@ void ScenarioRunWidget::refreshStatus() { .arg(static_cast(frame.totalAgentCount)) .arg(static_cast(frame.agents.size()))); } + if (eventLabel_ != nullptr) { + eventLabel_->setText(configuredEventSummary(scenario_)); + } if (evacuationProgressBar_ != nullptr) { evacuationProgressBar_->setValue(percentValue( static_cast(frame.evacuatedAgentCount), @@ -352,23 +417,22 @@ void ScenarioRunWidget::refreshStatus() { .arg(static_cast(risk.stalledAgentCount))); } if (congestionLabel_ != nullptr) { - const auto hotspotCount = risk.hotspots.empty() ? 0 : static_cast(risk.hotspots.front().agentCount); - congestionLabel_->setText(QString("Hotspots: %1%2") - .arg(static_cast(risk.hotspots.size())) - .arg(risk.hotspots.empty() ? QString{} : QString(" (max %1 agents)").arg(hotspotCount))); + congestionLabel_->setText(hotspotSummary(risk)); } if (bottleneckLabel_ != nullptr) { if (risk.bottlenecks.empty()) { bottleneckLabel_->setText("Bottlenecks: 0"); + bottleneckLabel_->setToolTip({}); } else { const auto& bottleneck = risk.bottlenecks.front(); const auto label = QString::fromStdString(bottleneck.label); const auto id = QString::fromStdString(bottleneck.connectionId); - const auto idLine = (!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{}; - bottleneckLabel_->setText(QString("Worst Bottleneck: %1%2\nNearby: %3, Stalled: %4") - .arg(label, idLine) + bottleneckLabel_->setText(QString("Worst Bottleneck\nNearby: %1, Stalled: %2") .arg(static_cast(bottleneck.nearbyAgentCount)) .arg(static_cast(bottleneck.stalledAgentCount))); + bottleneckLabel_->setToolTip(QString("%1%2") + .arg(label) + .arg((!id.isEmpty() && id != label) ? QString("\nID: %1").arg(id) : QString{})); } } if (pauseButton_ != nullptr) { @@ -380,10 +444,15 @@ void ScenarioRunWidget::refreshStatus() { pauseButton_->setEnabled(!frame.complete); } if (stopButton_ != nullptr) { + stopButton_->setToolTip("Reset run to start; no result is created"); + stopButton_->setAccessibleName("Reset run to start"); stopButton_->setEnabled(frame.totalAgentCount > 0); } if (resultButton_ != nullptr) { resultButton_->setEnabled(frame.complete && frame.totalAgentCount > 0); + resultButton_->setToolTip(frame.complete + ? QString("Open results: %1").arg(completionOutcome(frame)) + : "Available after evacuation completes or the time limit is reached"); } } @@ -427,6 +496,8 @@ void ScenarioRunWidget::showResults() { } }, backToLayoutReviewHandler_, + returnToAuthoringHandler_, + rerunScenarioHandler_, this); rootLayout->replaceWidget(shell_, resultWidget); shell_->hide(); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index 81663b8..c662f8e 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -28,6 +28,8 @@ class ScenarioRunWidget : public QWidget { std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::function returnToAuthoringHandler = {}, + std::function rerunScenarioHandler = {}, QWidget* parent = nullptr); private: @@ -45,6 +47,8 @@ class ScenarioRunWidget : public QWidget { std::function saveProjectHandler_{}; std::function openProjectHandler_{}; std::function backToLayoutReviewHandler_{}; + std::function returnToAuthoringHandler_{}; + std::function rerunScenarioHandler_{}; WorkspaceShell* shell_{nullptr}; SimulationCanvasWidget* canvas_{nullptr}; QTimer* timer_{nullptr}; @@ -53,6 +57,7 @@ class ScenarioRunWidget : public QWidget { QLabel* elapsedLabel_{nullptr}; QProgressBar* timeProgressBar_{nullptr}; QLabel* agentCountLabel_{nullptr}; + QLabel* eventLabel_{nullptr}; QProgressBar* evacuationProgressBar_{nullptr}; QLabel* riskLabel_{nullptr}; QLabel* congestionLabel_{nullptr}; diff --git a/src/application/SimulationCanvasWidget.cpp b/src/application/SimulationCanvasWidget.cpp index e79851d..3153ff8 100644 --- a/src/application/SimulationCanvasWidget.cpp +++ b/src/application/SimulationCanvasWidget.cpp @@ -6,10 +6,14 @@ #include #include +#include +#include #include #include #include +#include #include +#include #include namespace safecrowd::application { @@ -24,6 +28,28 @@ constexpr double kBottleneckFocusZoom = 2.4; constexpr int kHotspotMinCoreAlpha = 72; constexpr int kHotspotMaxCoreAlpha = 190; +QPushButton* createViewControlButton(const QString& text, const QString& tooltip, QWidget* parent) { + auto* button = new QPushButton(text, parent); + button->setFixedSize(text == "Fit" ? QSize(42, 30) : QSize(30, 30)); + button->setCursor(Qt::PointingHandCursor); + button->setToolTip(tooltip); + button->setAccessibleName(tooltip); + button->setStyleSheet( + "QPushButton {" + " background: #ffffff;" + " border: 1px solid #d7e0ea;" + " border-radius: 8px;" + " color: #16202b;" + " font-weight: 700;" + " padding: 0;" + "}" + "QPushButton:hover {" + " background: #eef3f8;" + " border-color: #b8c6d6;" + "}"); + return button; +} + } // namespace SimulationCanvasWidget::SimulationCanvasWidget(safecrowd::domain::FacilityLayout2D layout, QWidget* parent) @@ -32,8 +58,11 @@ SimulationCanvasWidget::SimulationCanvasWidget(safecrowd::domain::FacilityLayout setMouseTracking(true); setFocusPolicy(Qt::StrongFocus); setMinimumSize(520, 360); + setCursor(Qt::OpenHandCursor); setStyleSheet("QWidget { background: #f4f7fb; }"); + camera_.setPrimaryButtonPanEnabled(true); layoutBounds_ = collectLayoutCanvasBounds(layout_); + createViewControls(); QCoreApplication::instance()->installEventFilter(this); } @@ -109,9 +138,7 @@ void SimulationCanvasWidget::keyReleaseEvent(QKeyEvent* event) { void SimulationCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { - camera_.reset(); - layoutCacheValid_ = false; - update(); + resetView(); event->accept(); return; } @@ -120,7 +147,7 @@ void SimulationCanvasWidget::mouseDoubleClickEvent(QMouseEvent* event) { void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { if (camera_.updatePan(event)) { - layoutCacheValid_ = false; + invalidateLayoutCache(); update(); return; } @@ -130,6 +157,7 @@ void SimulationCanvasWidget::mouseMoveEvent(QMouseEvent* event) { void SimulationCanvasWidget::mousePressEvent(QMouseEvent* event) { setFocus(Qt::MouseFocusReason); if (camera_.beginPan(event)) { + setCursor(Qt::ClosedHandCursor); return; } QWidget::mousePressEvent(event); @@ -137,6 +165,7 @@ void SimulationCanvasWidget::mousePressEvent(QMouseEvent* event) { void SimulationCanvasWidget::mouseReleaseEvent(QMouseEvent* event) { if (camera_.finishPan(event)) { + setCursor(Qt::OpenHandCursor); return; } QWidget::mouseReleaseEvent(event); @@ -176,6 +205,11 @@ void SimulationCanvasWidget::paintEvent(QPaintEvent* event) { } } +void SimulationCanvasWidget::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + positionViewControls(); +} + void SimulationCanvasWidget::wheelEvent(QWheelEvent* event) { const auto bounds = collectBounds(); if (!bounds.has_value()) { @@ -183,13 +217,90 @@ void SimulationCanvasWidget::wheelEvent(QWheelEvent* event) { return; } if (camera_.zoomAt(event, *bounds, previewViewport())) { - layoutCacheValid_ = false; + invalidateLayoutCache(); update(); return; } QWidget::wheelEvent(event); } +void SimulationCanvasWidget::createViewControls() { + viewControls_ = new QFrame(this); + viewControls_->setObjectName("simulationViewControls"); + viewControls_->setStyleSheet( + "#simulationViewControls {" + " background: rgba(255, 255, 255, 230);" + " border: 1px solid #d7e0ea;" + " border-radius: 12px;" + "}" + ); + auto* layout = new QHBoxLayout(viewControls_); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(6); + + auto* zoomInButton = createViewControlButton("+", "Zoom in", viewControls_); + auto* zoomOutButton = createViewControlButton("-", "Zoom out", viewControls_); + auto* fitButton = createViewControlButton("Fit", "Fit to view", viewControls_); + layout->addWidget(zoomInButton); + layout->addWidget(zoomOutButton); + layout->addWidget(fitButton); + + connect(zoomInButton, &QPushButton::clicked, this, [this]() { + zoomAtCanvasPoint(rect().center(), 1.2); + }); + connect(zoomOutButton, &QPushButton::clicked, this, [this]() { + zoomAtCanvasPoint(rect().center(), 1.0 / 1.2); + }); + connect(fitButton, &QPushButton::clicked, this, [this]() { + resetView(); + }); + + viewControls_->adjustSize(); + positionViewControls(); +} + +void SimulationCanvasWidget::positionViewControls() { + if (viewControls_ == nullptr) { + return; + } + + viewControls_->adjustSize(); + const int margin = 14; + viewControls_->move(width() - viewControls_->width() - margin, margin); + viewControls_->raise(); +} + +void SimulationCanvasWidget::resetView() { + camera_.reset(); + invalidateLayoutCache(); + update(); +} + +void SimulationCanvasWidget::zoomAtCanvasPoint(const QPointF& anchorPoint, double factor) { + const auto bounds = collectBounds(); + if (!bounds.has_value() || factor <= 0.0) { + return; + } + + const auto viewport = previewViewport(); + if (viewport.width() <= 0.0 || viewport.height() <= 0.0) { + return; + } + + const LayoutCanvasTransform currentTransform(*bounds, viewport, camera_.zoom(), camera_.panOffset()); + const auto anchorWorld = currentTransform.unmap(anchorPoint); + camera_.setZoom(std::clamp(camera_.zoom() * factor, 0.1, 50.0)); + + const LayoutCanvasTransform updatedTransform(*bounds, viewport, camera_.zoom(), camera_.panOffset()); + camera_.setPanOffset(camera_.panOffset() + anchorPoint - updatedTransform.map(anchorWorld)); + invalidateLayoutCache(); + update(); +} + +void SimulationCanvasWidget::invalidateLayoutCache() { + layoutCacheValid_ = false; +} + std::optional SimulationCanvasWidget::collectBounds() const { return layoutBounds_; } @@ -242,7 +353,7 @@ void SimulationCanvasWidget::focusWorldPoint(const safecrowd::domain::Point2D& p const LayoutCanvasTransform transform(*bounds, viewport, camera_.zoom(), {}); camera_.setPanOffset(viewport.center() - transform.map(point)); - layoutCacheValid_ = false; + invalidateLayoutCache(); update(); } diff --git a/src/application/SimulationCanvasWidget.h b/src/application/SimulationCanvasWidget.h index 527acd3..1f21f18 100644 --- a/src/application/SimulationCanvasWidget.h +++ b/src/application/SimulationCanvasWidget.h @@ -13,10 +13,12 @@ #include "domain/ScenarioSimulationRunner.h" class QEvent; +class QFrame; class QKeyEvent; class QMouseEvent; class QPainter; class QPaintEvent; +class QResizeEvent; class QWheelEvent; namespace safecrowd::application { @@ -41,9 +43,15 @@ class SimulationCanvasWidget : public QWidget { void mousePressEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; void wheelEvent(QWheelEvent* event) override; private: + void createViewControls(); + void positionViewControls(); + void resetView(); + void zoomAtCanvasPoint(const QPointF& anchorPoint, double factor); + void invalidateLayoutCache(); std::optional collectBounds() const; LayoutCanvasTransform currentTransform(const LayoutCanvasBounds& bounds) const; void refreshLayoutCache(const LayoutCanvasBounds& bounds); @@ -59,6 +67,7 @@ class SimulationCanvasWidget : public QWidget { std::optional focusedHotspotIndex_{}; std::optional focusedBottleneckIndex_{}; LayoutCanvasCamera camera_{}; + QFrame* viewControls_{nullptr}; std::optional layoutBounds_{}; QPixmap layoutCache_{}; QSize layoutCacheSize_{}; diff --git a/src/application/WorkspaceShell.cpp b/src/application/WorkspaceShell.cpp index 04ee032..62d9f5a 100644 --- a/src/application/WorkspaceShell.cpp +++ b/src/application/WorkspaceShell.cpp @@ -240,7 +240,8 @@ QWidget* WorkspaceShell::createPanelHeader(const QString& title, QWidget* parent auto* label = new QLabel(title, header); label->setFont(ui::font(ui::FontRole::Title)); - label->setWordWrap(false); + label->setWordWrap(true); + label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); layout->addWidget(label, 1, Qt::AlignVCenter); return header; }