Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/application/ProjectWorkspaceState.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ enum class SavedResultNavigationView {
Hotspot,
Zone,
Groups,
Recommendations,
};

struct SavedScenarioState {
Expand Down
276 changes: 180 additions & 96 deletions src/application/ScenarioBatchResultWidget.cpp

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/application/ScenarioBatchResultWidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,16 @@ class ScenarioBatchResultWidget : public QWidget {
void pauseReplay();
void refreshComparisonSelection();
void refreshPressureComparisonTable();
void refreshRecommendationPanel();
void refreshResultNavigationPanel();
void refreshSelectedResult();
void rerunBatch();
void seekToTimingMarkerSeconds(double seconds);
void setRecommendationScenarioSelected(int index, bool selected);
void setOverlayMode(OverlayMode mode);
void showAuthoring(ScenarioAuthoringWidget::InitialState initialState);
void showClosestReplayFrameAtSeconds(double seconds);
void showReplayFrame(const safecrowd::domain::SimulationFrame& frame);
QWidget* createBatchRecommendationNavigationPanel();
int explicitBaselineResultIndex() const noexcept;
int baselineResultIndex() const noexcept;

Expand All @@ -88,6 +89,7 @@ class ScenarioBatchResultWidget : public QWidget {
std::function<void()> backToLayoutReviewHandler_{};
int currentResultIndex_{0};
std::vector<int> selectedCompareIndices_{};
std::vector<int> selectedRecommendationIndices_{};
std::vector<safecrowd::domain::SimulationFrame> replayFrames_{};
int replayFrameIndex_{0};
WorkspaceShell* shell_{nullptr};
Expand All @@ -98,7 +100,6 @@ class ScenarioBatchResultWidget : public QWidget {
QSlider* replaySlider_{nullptr};
QLabel* replayTimeLabel_{nullptr};
QLabel* detailLabel_{nullptr};
QWidget* recommendationPanel_{nullptr};
QTableWidget* pressureTable_{nullptr};
std::vector<QCheckBox*> compareCheckBoxes_{};
QWidget* remainingChart_{nullptr};
Expand Down
79 changes: 50 additions & 29 deletions src/application/ScenarioCanvasWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ struct PointBounds {

struct OccupantSourceSettings {
int agentsPerSpawn{kDefaultSourceAgentsPerSpawn};
int targetAgentCount{0};
double startSeconds{kDefaultSourceStartSeconds};
double durationSeconds{kDefaultSourceDurationSeconds};
double intervalSeconds{kDefaultSourceIntervalSeconds};
Expand All @@ -83,14 +84,15 @@ bool matchesFloor(const std::string& elementFloorId, const QString& floorId) {
return floorId.isEmpty() || elementFloorId.empty() || QString::fromStdString(elementFloorId) == floorId;
}

int sourceEmissionCount(int agentsPerSpawn, double durationSeconds, double intervalSeconds) {
int sourceEmissionCount(int agentsPerSpawn, double durationSeconds, double intervalSeconds, int targetAgentCount = 0) {
if (agentsPerSpawn <= 0 || durationSeconds <= 0.0 || intervalSeconds <= 1e-9) {
return 0;
}
const auto ticks = static_cast<long long>(
std::floor(std::max(0.0, durationSeconds - 1e-9) / intervalSeconds)) + 1;
const auto count = std::max<long long>(0, ticks) * static_cast<long long>(agentsPerSpawn);
return static_cast<int>(std::min<long long>(kMaxSourceOccupantCount, count));
const auto cappedCount = targetAgentCount > 0 ? std::min<long long>(targetAgentCount, count) : count;
return static_cast<int>(std::min<long long>(kMaxSourceOccupantCount, cappedCount));
}

bool editOccupantSourceSettings(
Expand Down Expand Up @@ -120,36 +122,49 @@ bool editOccupantSourceSettings(
intervalSpin->setSuffix(" sec");
intervalSpin->setValue(std::max(0.1, settings->intervalSeconds));

auto* startSpin = new QDoubleSpinBox(&dialog);
startSpin->setRange(0.0, 86400.0);
startSpin->setDecimals(1);
startSpin->setSuffix(" sec");
startSpin->setValue(std::max(0.0, settings->startSeconds));

auto* durationSpin = new QDoubleSpinBox(&dialog);
durationSpin->setRange(0.1, 1440.0);
durationSpin->setRange(0.1, 86400.0);
durationSpin->setDecimals(1);
durationSpin->setSuffix(" min");
durationSpin->setValue(std::max(0.1, settings->durationSeconds / 60.0));
durationSpin->setSuffix(" sec");
durationSpin->setValue(std::max(0.1, settings->durationSeconds));

auto* totalLabel = new QLabel(&dialog);
totalLabel->setStyleSheet("QLabel { color: #4f5d6b; }");
const auto refreshSummary = [=]() {
const auto total = sourceEmissionCount(
peopleSpin->value(),
durationSpin->value() * 60.0,
intervalSpin->value());
totalLabel->setText(QString("Total emitted: %1 people").arg(total));
durationSpin->value(),
intervalSpin->value(),
settings->targetAgentCount);
totalLabel->setText(QString("Total emitted: %1 people\nWindow: %2 - %3 sec")
.arg(total)
.arg(startSpin->value(), 0, 'f', 1)
.arg(startSpin->value() + durationSpin->value(), 0, 'f', 1));
};
refreshSummary();
QObject::connect(peopleSpin, qOverload<int>(&QSpinBox::valueChanged), &dialog, refreshSummary);
QObject::connect(intervalSpin, qOverload<double>(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary);
QObject::connect(startSpin, qOverload<double>(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary);
QObject::connect(durationSpin, qOverload<double>(&QDoubleSpinBox::valueChanged), &dialog, refreshSummary);

layout->addWidget(new QLabel("People each time", &dialog), 0, 0);
layout->addWidget(peopleSpin, 0, 1);
layout->addWidget(new QLabel("Every", &dialog), 1, 0);
layout->addWidget(intervalSpin, 1, 1);
layout->addWidget(new QLabel("Duration", &dialog), 2, 0);
layout->addWidget(durationSpin, 2, 1);
layout->addWidget(totalLabel, 3, 0, 1, 2);
layout->addWidget(new QLabel("Start", &dialog), 2, 0);
layout->addWidget(startSpin, 2, 1);
layout->addWidget(new QLabel("Duration", &dialog), 3, 0);
layout->addWidget(durationSpin, 3, 1);
layout->addWidget(totalLabel, 4, 0, 1, 2);

auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
layout->addWidget(buttons, 4, 0, 1, 2);
layout->addWidget(buttons, 5, 0, 1, 2);
QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);

Expand All @@ -159,8 +174,9 @@ bool editOccupantSourceSettings(
}

settings->agentsPerSpawn = peopleSpin->value();
settings->startSeconds = startSpin->value();
settings->intervalSeconds = intervalSpin->value();
settings->durationSeconds = durationSpin->value() * 60.0;
settings->durationSeconds = durationSpin->value();
return true;
}

Expand Down Expand Up @@ -2880,7 +2896,8 @@ void ScenarioCanvasWidget::addRouteGuidance(const QPointF& position) {
addRouteGuidanceForExitZone(*it);
return;
}
// If the user clicked inside a non-exit zone, still allow installing guidance by selecting a nearby door.
QMessageBox::information(this, "Route guidance", "Click an exit zone to install guidance.");
return;
}

constexpr double kPickRadiusPixels = 18.0;
Expand All @@ -2896,8 +2913,7 @@ void ScenarioCanvasWidget::addRouteGuidance(const QPointF& position) {
if (!matchesFloor(candidate.floorId, currentFloorId_)) {
continue;
}
if (candidate.kind != safecrowd::domain::ConnectionKind::Doorway
&& candidate.kind != safecrowd::domain::ConnectionKind::Exit) {
if (candidate.kind != safecrowd::domain::ConnectionKind::Exit) {
continue;
}
const auto halfWidth = std::max(0.0, candidate.effectiveWidth * 0.5);
Expand All @@ -2910,7 +2926,7 @@ void ScenarioCanvasWidget::addRouteGuidance(const QPointF& position) {
}

if (connection == nullptr) {
QMessageBox::information(this, "Route guidance", "Click an exit zone or a door to install guidance.");
QMessageBox::information(this, "Route guidance", "Click an exit zone to install guidance.");
return;
}

Expand Down Expand Up @@ -2946,28 +2962,31 @@ void ScenarioCanvasWidget::addRouteGuidanceForExitZone(const safecrowd::domain::
}

void ScenarioCanvasWidget::addRouteGuidanceForConnection(const safecrowd::domain::Connection2D& connection) {
if (connection.kind != safecrowd::domain::ConnectionKind::Doorway
&& connection.kind != safecrowd::domain::ConnectionKind::Exit) {
QMessageBox::information(this, "Route guidance", "This tool can only be used on exit zones or doors.");
if (connection.kind != safecrowd::domain::ConnectionKind::Exit) {
QMessageBox::information(this, "Route guidance", "This tool can only be used on exits.");
return;
}

for (const auto& existing : routeGuidances_) {
if (!existing.installConnectionId.empty() && existing.installConnectionId == connection.id) {
QMessageBox::information(this, "Route guidance", "Guidance is already installed on this door.");
return;
}
}

const auto exitZoneId = pickNearestExitZoneIdForConnection(layout_, connection);
if (exitZoneId.empty()) {
QMessageBox::information(this, "Route guidance", "No exit zone is connected to this exit.");
return;
}
const auto zoneIt = std::find_if(layout_.zones.begin(), layout_.zones.end(), [&](const auto& zone) {
return zone.id == exitZoneId;
});
if (zoneIt != layout_.zones.end()) {
addRouteGuidanceForExitZone(*zoneIt);
return;
}

safecrowd::domain::RouteGuidanceDraft draft;
draft.id = nextRouteGuidanceId().toStdString();
draft.startSeconds = 0.0;
draft.endSeconds = 0.0;
draft.periods.clear();
draft.guidedExitZoneId = exitZoneId;
draft.installConnectionId = connection.id;
draft.installConnectionId.clear();
draft.baseComplianceRate = 0.5;
draft.guidanceStrength = 0.55;
draft.maxDetourMeters = 20.0;
Expand Down Expand Up @@ -3143,6 +3162,7 @@ bool ScenarioCanvasWidget::editOccupantSourceById(const QString& sourceId, const

OccupantSourceSettings settings{
.agentsPerSpawn = std::max(1, placementIt->sourceAgentsPerSpawn),
.targetAgentCount = placementIt->occupantCount,
.startSeconds = placementIt->sourceStartSeconds,
.durationSeconds = std::max(0.1, placementIt->sourceEndSeconds - placementIt->sourceStartSeconds),
.intervalSeconds = std::max(0.1, placementIt->sourceIntervalSeconds),
Expand All @@ -3158,7 +3178,8 @@ bool ScenarioCanvasWidget::editOccupantSourceById(const QString& sourceId, const
placementIt->occupantCount = sourceEmissionCount(
placementIt->sourceAgentsPerSpawn,
settings.durationSeconds,
placementIt->sourceIntervalSeconds);
placementIt->sourceIntervalSeconds,
settings.targetAgentCount);
emitPlacementsChanged();
update();
return true;
Expand Down
98 changes: 97 additions & 1 deletion src/application/ScenarioResultNavigation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,21 @@ QIcon makeResultNavigationIcon(const QString& tabId, const QColor& color) {
painter.drawRoundedRect(QRectF(11, 11, 22, 22), 4, 4);
painter.drawLine(QPointF(22, 11), QPointF(22, 33));
painter.drawLine(QPointF(11, 22), QPointF(33, 22));
} else {
} else if (tabId == "groups") {
painter.drawEllipse(QPointF(22, 14), 5, 5);
painter.drawEllipse(QPointF(14, 20), 4, 4);
painter.drawEllipse(QPointF(30, 20), 4, 4);
painter.drawArc(QRectF(12, 22, 20, 14), 20 * 16, 140 * 16);
painter.drawArc(QRectF(5, 26, 18, 10), 30 * 16, 120 * 16);
painter.drawArc(QRectF(21, 26, 18, 10), 30 * 16, 120 * 16);
} else {
painter.drawRoundedRect(QRectF(12, 12, 20, 20), 4, 4);
painter.drawLine(QPointF(16, 18), QPointF(24, 18));
painter.drawLine(QPointF(24, 18), QPointF(21, 15));
painter.drawLine(QPointF(24, 18), QPointF(21, 21));
painter.drawLine(QPointF(28, 26), QPointF(20, 26));
painter.drawLine(QPointF(20, 26), QPointF(23, 23));
painter.drawLine(QPointF(20, 26), QPointF(23, 29));
}

return QIcon(pixmap);
Expand Down Expand Up @@ -334,6 +342,11 @@ QWidget* createGroupsReportPanel(const safecrowd::domain::ScenarioResultArtifact
return parts.panel;
}

bool shouldShowRecommendationEvidence(const safecrowd::domain::AlternativeRecommendationEvidence& item) {
const auto label = QString::fromStdString(item.label);
return !label.startsWith("Risk ") && label != "Critical pressure events";
}

} // namespace

std::vector<WorkspaceNavigationTab> scenarioResultNavigationTabs() {
Expand All @@ -358,6 +371,11 @@ std::vector<WorkspaceNavigationTab> scenarioResultNavigationTabs() {
.label = "Groups",
.icon = makeResultNavigationIcon("groups", QColor("#1f5fae")),
},
{
.id = "recommendations",
.label = "Recommendations",
.icon = makeResultNavigationIcon("recommendations", QColor("#1f5fae")),
},
};
}

Expand All @@ -369,6 +387,8 @@ QString scenarioResultNavigationTabId(ScenarioResultNavigationView view) {
return "zone";
case ScenarioResultNavigationView::Groups:
return "groups";
case ScenarioResultNavigationView::Recommendations:
return "recommendations";
case ScenarioResultNavigationView::Bottleneck:
default:
return "bottleneck";
Expand All @@ -385,6 +405,9 @@ ScenarioResultNavigationView scenarioResultNavigationViewFromTabId(const QString
if (tabId == "groups") {
return ScenarioResultNavigationView::Groups;
}
if (tabId == "recommendations") {
return ScenarioResultNavigationView::Recommendations;
}
return ScenarioResultNavigationView::Bottleneck;
}

Expand All @@ -402,10 +425,83 @@ QWidget* createScenarioResultNavigationPanel(
return createZoneReportPanel(artifacts, parent);
case ScenarioResultNavigationView::Groups:
return createGroupsReportPanel(artifacts, parent);
case ScenarioResultNavigationView::Recommendations:
return createResultReportPanel("Recommendations", "Recommended operational changes", parent).panel;
case ScenarioResultNavigationView::Bottleneck:
default:
return createBottleneckReportPanel(risk, std::move(bottleneckFocusHandler), parent);
}
}

QWidget* createScenarioRecommendationNavigationPanel(
const safecrowd::domain::AlternativeRecommendationResult& recommendation,
std::function<void(safecrowd::domain::ScenarioDraft)> createScenarioHandler,
QWidget* parent) {
auto parts = createResultReportPanel("Recommendations", "Operational alternatives for this result", parent);
if (recommendation.candidates.empty()) {
const auto message = recommendation.blockingReasons.empty()
? QString("No actionable recommendation for this result.")
: QString::fromStdString(recommendation.blockingReasons.front());
auto* empty = createLabel(message, parts.content, ui::FontRole::Caption);
empty->setStyleSheet(ui::mutedTextStyleSheet());
parts.contentLayout->addWidget(empty);
parts.contentLayout->addStretch(1);
return parts.panel;
}

for (const auto& candidate : recommendation.candidates) {
auto* section = new QFrame(parts.content);
section->setStyleSheet(ui::panelStyleSheet());
section->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);
section->setMinimumWidth(0);
auto* sectionLayout = new QVBoxLayout(section);
sectionLayout->setContentsMargins(14, 12, 14, 12);
sectionLayout->setSpacing(6);

auto* title = createLabel(QString::fromStdString(candidate.title), section, ui::FontRole::Body);
title->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
title->setStyleSheet("QLabel { color: #16202b; font-weight: 600; }");
sectionLayout->addWidget(title);

auto* summary = createLabel(QString::fromStdString(candidate.summary), section, ui::FontRole::Caption);
summary->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
summary->setStyleSheet(ui::mutedTextStyleSheet());
sectionLayout->addWidget(summary);

for (const auto& item : candidate.evidence) {
if (!shouldShowRecommendationEvidence(item)) {
continue;
}
auto* evidenceLabel = createLabel(
QString("%1: %2")
.arg(QString::fromStdString(item.label),
QString::fromStdString(item.value)),
section,
ui::FontRole::Caption);
evidenceLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
evidenceLabel->setStyleSheet(ui::subtleTextStyleSheet());
evidenceLabel->setToolTip(QString::fromStdString(item.source));
sectionLayout->addWidget(evidenceLabel);
}

auto* button = new QPushButton("Create Scenario", section);
button->setFont(ui::font(ui::FontRole::Body));
button->setStyleSheet(ui::secondaryButtonStyleSheet());
button->setCursor(Qt::PointingHandCursor);
button->setMinimumWidth(0);
button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
button->setToolTip("Create recommended scenario");
sectionLayout->addWidget(button);
QObject::connect(button, &QPushButton::clicked, section, [createScenarioHandler, scenario = candidate.recommendedScenario]() {
if (createScenarioHandler) {
createScenarioHandler(scenario);
}
});

parts.contentLayout->addWidget(section);
}
parts.contentLayout->addStretch(1);
return parts.panel;
}

} // namespace safecrowd::application
Loading
Loading