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
6 changes: 6 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ find_package(nav2_msgs REQUIRED)
find_package(pluginlib REQUIRED)
find_package(marine_autonomy REQUIRED)
find_package(marine_colormap REQUIRED)
find_package(marine_colormap_widgets REQUIRED)
find_package(marine_interfaces REQUIRED)
find_package(marine_tiled_raster_store REQUIRED)
find_package(marine_nav_interfaces REQUIRED)
Expand Down Expand Up @@ -260,6 +261,7 @@ set(CAMP_MAP_SOURCES
src/camp_map/map_tree_view/map_tree_view.cpp
src/camp_map/map_view/map_view.cpp
src/camp_map/map_view/web_mercator.cpp
src/camp_map/raster/colormap_range_dialog.cpp
src/camp_map/raster/raster_gl_renderer.cpp
src/camp_map/raster/raster_layer.cpp
src/camp_map/raster/gggs_tile.cpp
Expand Down Expand Up @@ -301,6 +303,10 @@ target_link_libraries(camp_map
# consumer of those headers (camp_map_ros, the camp executable, the tests).
marine_colormap::marine_colormap
PRIVATE
# [camp#142 PR2] The interactive colorbar dialog (colormap_range_dialog.cpp)
# is the only user of the widget, and only in the .cpp — the public header
# forward-declares QWidget and exposes no widget type — so link PRIVATE.
marine_colormap_widgets::marine_colormap_widgets
${GDAL_LIBRARY}
)

Expand Down
84 changes: 84 additions & 0 deletions docs/decisions/0009-colormap-range-colorbar-placement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ADR-0009: Place the interactive colorbar in a context-menu dialog

## Status

Accepted

Implements CAMP issue [#142](https://github.com/rolker/camp/issues/142) — give the
operator an interactive way to override a scalar layer's colormap range when an
outlier skews the auto-range (the motivating case: backscatter band 2 with
Max ≈ 925 / Mean ≈ 0.16 collapses the auto colorbar). Builds on the per-layer
`marine_colormap::RangeModel` and numeric "Set range…" prompts landed in PR1
(also #142), and on [ADR-0008](0008-adopt-marine-colormap-lut-bake.md) (the shared
`marine_colormap` palettes) and [ADR-0007](0007-raster-field-source-interface.md)
(the unified raster render path the three scalar layers share).

> **Numbering.** This is **camp ADR-0009**, a *project* ADR in this repo's
> `docs/decisions/` series — independent of the workspace agent-framework ADR
> series, where the numbers collide by coincidence (see ADR-0007/0008).

## Context

PR1 shipped the range-override backend (`RangeModel` Auto/Manual on
`GggsTileLayer`, `RasterLayer`, `SonarLiveCacheLayer`; `setRangeOverride` /
`resetRangeToAuto`; per-layer persistence) with a minimal UI: two sequential
`QInputDialog::getDouble` prompts. That works but is blind — the operator types
numbers without seeing the palette, the data extents, or where the handles sit
relative to the outlier.

The shared `marine_colormap_widgets::ColormapLegendWidget` (marine_colormap
ADR-0002) paints the value→colour ramp and lets the operator **drag** min/max
handles to pin a `Manual` range. PR2 embeds it. A draggable widget cannot live
*inside* a transient `QMenu` (the menu closes on click), so "context menu" means a
small window opened *from* the menu.

Three placements were considered:

1. **Modal dialog opened from the layer context menu** — one entry, "Colormap
range…", per scalar layer. No permanent screen real estate; discoverable from
the same place as the colormap/band actions.
2. **Docked panel in the Layers tab** showing the selected layer's colorbar —
always visible, better for continuous live tweaking, but needs panel layout
work and a selection-follows-colorbar wiring.
3. **Map overlay** near the active layer (scale-bar style) — closest to a chart
legend, but the most placement/occlusion code.

## Decision

Use **option 1**: a modal `QDialog` opened from each scalar layer's context menu
("Colormap range…"), hosting the `ColormapLegendWidget` plus min/max
`QDoubleSpinBox`es. Implemented once in `raster/colormap_range_dialog.{h,cpp}`
(`camp::raster::showColormapRangeDialog`) and called from all three layers, so the
wiring is not duplicated. This replaces PR1's two numeric prompts; the spin boxes
preserve precise numeric entry, now kept in sync with the bar.

Wiring contract:

- On open, the dialog seeds the widget from a `ColormapRangeState` snapshot
(palette index, data extents as the domain, current mode/lo/hi). An existing
`Manual` override is shown via `ColormapLegendWidget::setManual` (the slot added
in marine_colormap#10 precisely so a consumer can display a persisted override);
otherwise the bar tracks Auto.
- The widget's `rangeChanged` fires for both a manual pin and an auto fold; the
dialog disambiguates on `mode()` — `Manual` → `on_range(lo, hi)` (the layer's
`setRangeOverride`), otherwise `on_reset()` (the layer's `resetRangeToAuto`).
- Edits fire **live** while the dialog is open, so the map behind it re-renders as
the operator drags or types.

## Consequences

- The colorbar is a per-layer, on-demand tool — no always-on UI, no layout churn.
- The shared dialog keeps the three layers' menus identical and DRY; a future
fourth scalar layer reuses it.
- Modal `exec()` blocks the rest of the UI while open (the map still repaints
underneath). Acceptable for a brief range adjustment; a future modeless or docked
variant (option 2) can reuse the same `ColormapRangeState` seam if continuous
multi-layer tweaking becomes a need.
- The dialog is thin Qt glue over already-tested components — the widget's
`setManual`/drag/reset/`rangeChanged` (marine_colormap gtest) and the layers'
`setRangeOverride`/`resetRangeToAuto`/persistence (PR1 `test_range_persist`). The
modal event loop makes the glue itself impractical to unit-test headlessly, so it
is validated by manual exercise in the deployed app (consistent with how the #108
band picker was verified).
- The uncertainty-band default (`quality` palette + `[0, threshold]` on open) is a
separate follow-up (needs #104 band semantics), not part of this dialog.
1 change: 1 addition & 0 deletions package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<depend>pluginlib</depend>
<depend>marine_autonomy</depend>
<depend>marine_colormap</depend>
<depend>marine_colormap_widgets</depend>
<depend>marine_interfaces</depend>
<depend>marine_tiled_raster_store</depend>
<depend>marine_nav_interfaces</depend>
Expand Down
136 changes: 136 additions & 0 deletions src/camp_map/raster/colormap_range_dialog.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2026 Roland Arsenault
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#include "colormap_range_dialog.h"

#include <QDialog>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QSignalBlocker>
#include <QVBoxLayout>

#include "marine_colormap_widgets/colormap_legend_widget.hpp"

namespace camp
{
namespace raster
{

void showColormapRangeDialog(
QWidget * parent, const QString & title, const ColormapRangeState & state,
std::function<void(float, float)> on_range, std::function<void()> on_reset)
{
QDialog dialog(parent);
dialog.setWindowTitle(title);

auto * layout = new QVBoxLayout(&dialog);
layout->addWidget(new QLabel(
"Drag the handles or edit the bounds to pin a manual range; "
"reset to track the data automatically.", &dialog));

auto * legend = new marine_colormap_widgets::ColormapLegendWidget(&dialog);
legend->setPalette(state.palette_index);

// The colorbar domain is the data extent the handles slide within. When no data
// has been folded yet (data_min > data_max, the layers' crossed sentinel), fall
// back to the resolved range; widen a degenerate point so the handles have room.
float domain_min = state.data_min;
float domain_max = state.data_max;
if (domain_min > domain_max) {
domain_min = state.lo;
domain_max = state.hi;
}
if (domain_min >= domain_max) {
domain_min -= 0.5f;
domain_max += 0.5f;
}
legend->setDomain(domain_min, domain_max);
layout->addWidget(legend);

// Min/max spin boxes give precise entry alongside the drag (the numeric path
// PR1 offered, now folded into the same dialog and kept in sync with the bar).
auto * form = new QFormLayout();
auto * min_spin = new QDoubleSpinBox(&dialog);
auto * max_spin = new QDoubleSpinBox(&dialog);
for (QDoubleSpinBox * spin : {min_spin, max_spin}) {
spin->setRange(-1.0e9, 1.0e9);
spin->setDecimals(6);
}
form->addRow("Minimum:", min_spin);
form->addRow("Maximum:", max_spin);
layout->addLayout(form);

// Seed the widget to the layer's current state BEFORE wiring callbacks, so the
// seeding emissions don't echo back to the layer or the spin boxes.
if (state.mode == marine_colormap::RangeMode::Manual) {
legend->setManual(state.lo, state.hi);
} else {
legend->updateAuto(domain_min, domain_max);
}
// Initialise the spin boxes from the resolved bar state so the two always agree.
min_spin->setValue(legend->lo());
max_spin->setValue(legend->hi());

// The widget's rangeChanged fires for both a manual pin and a reset/auto fold;
// mode() disambiguates which callback to run. The spin boxes mirror the bar.
QObject::connect(
legend, &marine_colormap_widgets::ColormapLegendWidget::rangeChanged,
[legend, min_spin, max_spin, on_range, on_reset](float lo, float hi) {
{
const QSignalBlocker block_min(min_spin);
const QSignalBlocker block_max(max_spin);
min_spin->setValue(lo);
max_spin->setValue(hi);
}
if (legend->mode() == marine_colormap::RangeMode::Manual) {
if (on_range) {
on_range(lo, hi);
}
} else if (on_reset) {
on_reset();
}
});

// Editing either spin box pins a manual range on the bar (which then echoes
// back through rangeChanged to update the layer and re-sync the boxes).
auto pin_from_spins = [legend, min_spin, max_spin]() {
legend->setManual(
static_cast<float>(min_spin->value()), static_cast<float>(max_spin->value()));
};
QObject::connect(min_spin, &QDoubleSpinBox::editingFinished, legend, pin_from_spins);
QObject::connect(max_spin, &QDoubleSpinBox::editingFinished, legend, pin_from_spins);

auto * buttons = new QHBoxLayout();
auto * reset_btn = new QPushButton("Reset to auto", &dialog);
QObject::connect(reset_btn, &QPushButton::clicked, legend, [legend, domain_min, domain_max]() {
legend->reset(); // model -> Auto (emits rangeChanged -> on_reset)
legend->updateAuto(domain_min, domain_max); // move handles back across the domain
});
auto * close_btn = new QPushButton("Close", &dialog);
close_btn->setDefault(true);
QObject::connect(close_btn, &QPushButton::clicked, &dialog, &QDialog::accept);
buttons->addWidget(reset_btn);
buttons->addStretch();
buttons->addWidget(close_btn);
layout->addLayout(buttons);

dialog.exec();
}

} // namespace raster
} // namespace camp
62 changes: 62 additions & 0 deletions src/camp_map/raster/colormap_range_dialog.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2026 Roland Arsenault
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#ifndef CAMP_MAP__RASTER__COLORMAP_RANGE_DIALOG_H_
#define CAMP_MAP__RASTER__COLORMAP_RANGE_DIALOG_H_

#include <functional>

#include <QString>

#include "marine_colormap/transfer.hpp"

class QWidget;

namespace camp
{
namespace raster
{

/// A layer's current colormap-range state, passed into showColormapRangeDialog()
/// to seed the interactive colorbar (camp#142). The three scalar raster layers
/// (GggsTileLayer, RasterLayer, SonarLiveCacheLayer) each own a
/// marine_colormap::RangeModel; this is the snapshot the dialog needs to open
/// reflecting that model.
struct ColormapRangeState
{
int palette_index = 0; ///< marine_colormap registry index for the painted ramp
float data_min = 0.0f; ///< data extent low (colorbar domain); data_min > data_max == no data
float data_max = 1.0f; ///< data extent high
marine_colormap::RangeMode mode = marine_colormap::RangeMode::Auto;
float lo = 0.0f; ///< current resolved low bound (the active window)
float hi = 1.0f; ///< current resolved high bound
};

/// Open a modal dialog hosting marine_colormap_widgets::ColormapLegendWidget plus
/// min/max spin boxes for one layer's colormap range (camp#142 PR2, the
/// interactive successor to PR1's numeric prompts).
///
/// Dragging a handle (or editing a spin box) pins a Manual override and fires
/// `on_range(lo, hi)`; the "Reset to auto" button returns to the data-driven
/// Auto range and fires `on_reset()`. Both fire **live** while the dialog is open
/// so the map re-renders as the operator adjusts. The colorbar and spin boxes
/// stay in sync with each other.
void showColormapRangeDialog(
QWidget * parent, const QString & title, const ColormapRangeState & state,
std::function<void(float, float)> on_range, std::function<void()> on_reset);

} // namespace raster
} // namespace camp

#endif // CAMP_MAP__RASTER__COLORMAP_RANGE_DIALOG_H_
42 changes: 20 additions & 22 deletions src/camp_map/raster/gggs_tile_layer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "gggs_tile.h"
#include "gggs_tile_util.h"
#include "colormap_range_dialog.h"
#include "../map_view/web_mercator.h"

#include <marine_colormap/palette.hpp>
Expand All @@ -10,7 +11,6 @@
#include <QDir>
#include <QFileInfo>
#include <QGeoCoordinate>
#include <QInputDialog>
#include <QMenu>
#include <QSet>
#include <QSettings>
Expand Down Expand Up @@ -644,29 +644,27 @@ void GggsTileLayer::contextMenu(QMenu* menu)
connect(action, &QAction::triggered, this, [this, name]() { setColormap(name); });
}

// [camp#142] Colormap range override. GGGS tiles are always scalar values, so the
// submenu is offered unconditionally (same gating as the Colormap submenu above).
// "Set range…" prompts for lo then hi (pre-filled with the current resolved range)
// and pins a Manual override; "Reset to auto" returns to the data-driven extents.
QMenu* range_menu = menu->addMenu("Colormap range");
QAction* set_range = range_menu->addAction("Set range…");
connect(set_range, &QAction::triggered, this, [this]()
// [camp#142 PR2] Colormap range override. GGGS tiles are always scalar, so the
// action is offered unconditionally (same gating as the Colormap submenu above).
// Opens the interactive colorbar — drag the handles or edit the bounds to pin a
// Manual override, reset to track the data extents — the successor to PR1's
// sequential numeric prompts.
QAction* range_action = menu->addAction("Colormap range…");
connect(range_action, &QAction::triggered, this, [this]()
{
bool ok = false;
const double lo = QInputDialog::getDouble(
nullptr, "Colormap range", "Minimum:", range_model_.lo(),
-1.0e9, 1.0e9, 6, &ok);
if(!ok)
return;
const double hi = QInputDialog::getDouble(
nullptr, "Colormap range", "Maximum:", range_model_.hi(),
-1.0e9, 1.0e9, 6, &ok);
if(!ok)
return;
setRangeOverride(float(lo), float(hi));
ColormapRangeState state;
const auto idx = marine_colormap::palette_index(renderer_.colormap());
state.palette_index = idx ? static_cast<int>(*idx) : 0;
state.data_min = static_cast<float>(data_min_);
state.data_max = static_cast<float>(data_max_);
state.mode = range_model_.mode();
state.lo = range_model_.lo();
state.hi = range_model_.hi();
showColormapRangeDialog(
nullptr, "Colormap range", state,
[this](float lo, float hi) { setRangeOverride(lo, hi); },
[this]() { resetRangeToAuto(); });
});
QAction* reset_range = range_menu->addAction("Reset to auto");
connect(reset_range, &QAction::triggered, this, [this]() { resetRangeToAuto(); });

// [camp#108] Band picker — only for multi-band tile-sets (bathy depth +
// uncertainty, backscatter intensity + quality). Single-band stores (the
Expand Down
Loading
Loading