Skip to content

Commit 00d8ec4

Browse files
authored
feat(ui): highlight the stratigraphic unit on map when clicked in column (#129)
* feat: highlight the selected stratigraphic unit on the map Select a unit row in the stratigraphic column to select the features of that unit in the units layer, so that QGIS highlights them on the map. The match uses the unit-name field set under the column. Select an unconformity row, or clear the selection, to remove the highlight. Focus on a field of a unit row also selects that row, because clicks on the row's fields do not reach the list. * feat: apply stratigraphic thickness to the map layer Add a button to the stratigraphic column that writes a 'strat_thickness' field onto the selected units layer. The field holds the thickness of the matching unit in the column. Features that do not match a unit stay empty. The layer then gets a graduated renderer with one class for each thickness value, coloured with the selected colour ramp. Move the colour ramp lookup into a helper, so that the age and thickness styling use the same lookup. * style: apply black formatting
1 parent 6ceb907 commit 00d8ec4

4 files changed

Lines changed: 289 additions & 7 deletions

File tree

‎loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType
2-
from qgis.core import QgsApplication, QgsMapLayerProxyModel, QgsStyle
2+
from qgis.core import QgsApplication, QgsExpression, QgsMapLayerProxyModel, QgsStyle
33
from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox
44
from qgis.PyQt.QtCore import QSize
55
from qgis.PyQt.QtGui import QIcon
@@ -67,10 +67,15 @@ def __init__(self, parent=None, data_manager=None):
6767
# layer/field is selected, so the name-match warning is skipped.
6868
self._known_unit_names = None
6969

70+
# The layer whose features were selected to highlight the selected
71+
# unit on the map, so the selection can be cleared later.
72+
self._highlighted_layer = None
73+
7074
# Main list widget
7175
self.unitList = QListWidget()
7276
self.unitList.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
7377
self.unitList.model().rowsMoved.connect(self.update_order)
78+
self.unitList.itemSelectionChanged.connect(self.highlight_selected_unit)
7479
layout.addWidget(self.unitList)
7580

7681
# A single row of icon-only actions for building/clearing the column.
@@ -170,6 +175,18 @@ def __init__(self, parent=None, data_manager=None):
170175
)
171176
applyAgeButton.clicked.connect(self.apply_age_to_layer)
172177
ageRow.addWidget(applyAgeButton)
178+
179+
applyThicknessButton = self._make_tool_button(
180+
"mActionMeasure.svg", "Apply Stratigraphic Thickness to Map Layer"
181+
)
182+
applyThicknessButton.setToolTip(
183+
"Apply Stratigraphic Thickness to Map Layer\n"
184+
"Write a 'strat_thickness' field (the thickness of each unit in the "
185+
"column) onto the selected layer above and style it with a graduated "
186+
"colour ramp."
187+
)
188+
applyThicknessButton.clicked.connect(self.apply_thickness_to_layer)
189+
ageRow.addWidget(applyThicknessButton)
173190
layout.addLayout(ageRow)
174191

175192
self._guess_units_layer()
@@ -413,6 +430,7 @@ def _on_units_field_changed(self, _field_name):
413430
"""Persist and re-validate when the unit-name field selection changes."""
414431
self._persist_units_layer_selection()
415432
self._revalidate_unit_names()
433+
self.highlight_selected_unit()
416434

417435
def _on_units_layer_changed(self, layer):
418436
"""Update the field combo box when the units layer changes."""
@@ -424,6 +442,7 @@ def _on_units_layer_changed(self, layer):
424442
self.unitsLayerFieldComboBox.setField(unit_match)
425443
self._persist_units_layer_selection()
426444
self._revalidate_unit_names()
445+
self.highlight_selected_unit()
427446

428447
def _get_known_unit_names(self):
429448
"""Return the set of exact unit-name values in the selected geology layer/field.
@@ -454,6 +473,47 @@ def _revalidate_unit_names(self):
454473
if hasattr(widget, 'set_known_unit_names'):
455474
widget.set_known_unit_names(self._known_unit_names)
456475

476+
def highlight_selected_unit(self):
477+
"""Select the features of the selected unit in the units layer, so
478+
that QGIS highlights them on the map.
479+
480+
Clears the highlight when no unit row is selected (for example an
481+
unconformity row) or when no units layer/field is set.
482+
"""
483+
try:
484+
items = self.unitList.selectedItems()
485+
except RuntimeError:
486+
# Widget was deleted
487+
return
488+
widget = self.unitList.itemWidget(items[0]) if items else None
489+
layer = self.unitsLayerComboBox.currentLayer()
490+
field_name = self.unitsLayerFieldComboBox.currentField()
491+
492+
self._clear_unit_highlight()
493+
if (
494+
not isinstance(widget, StratigraphicUnitWidget)
495+
or not widget.name
496+
or layer is None
497+
or not field_name
498+
or layer.fields().indexFromName(field_name) < 0
499+
):
500+
return
501+
expression = f"{QgsExpression.quotedColumnRef(field_name)} = {QgsExpression.quotedValue(widget.name)}"
502+
layer.selectByExpression(expression)
503+
self._highlighted_layer = layer
504+
505+
def _clear_unit_highlight(self):
506+
"""Remove the feature selection made by highlight_selected_unit."""
507+
layer = self._highlighted_layer
508+
self._highlighted_layer = None
509+
if layer is None:
510+
return
511+
try:
512+
layer.removeSelection()
513+
except RuntimeError:
514+
# Layer was removed from the project
515+
pass
516+
457517
def apply_colours_to_layer(self):
458518
"""Push the stratigraphic column's colours onto the selected units layer."""
459519
if not self.data_manager:
@@ -514,6 +574,39 @@ def apply_age_to_layer(self):
514574
"units, or no features matched a stratigraphic unit.",
515575
)
516576

577+
def apply_thickness_to_layer(self):
578+
"""Write each unit's thickness onto the selected units layer and style it by a graduated ramp."""
579+
if not self.data_manager:
580+
print("Error: Data manager is not initialized.")
581+
return
582+
layer = self.unitsLayerComboBox.currentLayer()
583+
field_name = self.unitsLayerFieldComboBox.currentField()
584+
if layer is None or not field_name:
585+
QMessageBox.warning(
586+
self,
587+
"Apply Stratigraphic Thickness to Map Layer",
588+
"Please select a units layer and unit name field above.",
589+
)
590+
return
591+
ramp_name = self.strat_ageColorRampComboBox.currentText()
592+
applied = self.data_manager.apply_stratigraphic_thickness_to_layer(
593+
layer, field_name, ramp_name=ramp_name
594+
)
595+
if applied:
596+
QMessageBox.information(
597+
self,
598+
"Apply Stratigraphic Thickness to Map Layer",
599+
f"Applied stratigraphic thickness and graduated styling to layer "
600+
f"'{layer.name()}'.",
601+
)
602+
else:
603+
QMessageBox.warning(
604+
self,
605+
"Apply Stratigraphic Thickness to Map Layer",
606+
"Could not apply stratigraphic thickness. The stratigraphic column may "
607+
"have no units, or no features matched a stratigraphic unit.",
608+
)
609+
517610
def add_unit(self, *, unit_data=None, create_new=True):
518611
if unit_data is None:
519612
unit_data = {'type': 'unit', 'name': ''}
@@ -545,6 +638,7 @@ def add_unit(self, *, unit_data=None, create_new=True):
545638
unit_widget.nameChanged.connect(
546639
lambda: self.update_element(unit_widget)
547640
) # Connect name change signal
641+
unit_widget.nameChanged.connect(lambda: self._on_unit_name_changed(unit_widget))
548642

549643
unit_widget.thicknessChanged.connect(
550644
lambda: self.update_element(unit_widget)
@@ -561,6 +655,7 @@ def add_unit(self, *, unit_data=None, create_new=True):
561655
item.setSizeHint(unit_widget.sizeHint())
562656
self._add_list_item(item, at_top=create_new)
563657
self.unitList.setItemWidget(item, unit_widget)
658+
unit_widget.focused.connect(lambda: self.unitList.setCurrentItem(item))
564659
unit_widget.setData(unit_data) # Set data for the unit widget
565660
unit_widget.set_known_unit_names(self._known_unit_names)
566661

@@ -608,6 +703,15 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True):
608703
# Cache the widget for efficient updates
609704
self._widget_cache[unconformity.uuid] = (unconformity_widget, item)
610705

706+
def _on_unit_name_changed(self, unit_widget):
707+
"""Update the map highlight when the selected unit is renamed."""
708+
try:
709+
items = self.unitList.selectedItems()
710+
except RuntimeError:
711+
return
712+
if items and self.unitList.itemWidget(items[0]) is unit_widget:
713+
self.highlight_selected_unit()
714+
611715
def _add_list_item(self, item, *, at_top):
612716
"""Add a row to the list. A new element goes on top of the column
613717
(the end of `order`), so it is shown in the first row."""

‎loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class StratigraphicUnitWidget(QWidget):
1717
dragHandlePressed = pyqtSignal() # Drag handle mouse-down
1818
dragHandleMoved = pyqtSignal(QPoint) # Drag handle mouse-move (global pos)
1919
dragHandleReleased = pyqtSignal() # Drag handle mouse-up
20+
focused = pyqtSignal() # One of the row's fields got keyboard focus
2021

2122
def __init__(
2223
self,
@@ -53,8 +54,15 @@ def __init__(
5354
self._dragging_handle = False
5455
self.dragHandle.setCursor(Qt.CursorShape.SizeVerCursor)
5556
self.dragHandle.installEventFilter(self)
57+
# Clicks on the row's fields do not reach the QListWidget, so tell the
58+
# column when one of them gets focus, to make this the selected row.
59+
self._focus_widgets = (self.lineEditName, self.spinBoxThickness, self.buttonColour)
60+
for widget in self._focus_widgets:
61+
widget.installEventFilter(self)
5662

5763
def eventFilter(self, obj, event):
64+
if obj in self._focus_widgets and event.type() == QEvent.Type.FocusIn:
65+
self.focused.emit()
5866
if obj is self.dragHandle:
5967
event_type = event.type()
6068
if (

‎loopstructural/main/data_manager.py‎

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@
3131
from .vectorLayerWrapper import qgsLayerToGeoDataFrame
3232

3333

34+
def _lookup_colour_ramp(ramp_name):
35+
"""Return the named QGIS colour ramp, or the first available ramp if it is not found."""
36+
style = QgsStyle().defaultStyle()
37+
ramp = style.colorRamp(ramp_name) if ramp_name else None
38+
if ramp is None:
39+
ramp_names = style.colorRampNames()
40+
if ramp_names:
41+
ramp = style.colorRamp(ramp_names[0])
42+
return ramp
43+
44+
3445
def _colour_to_qcolor(colour):
3546
"""Convert a stratigraphic unit colour (hex string, colour name, or RGB tuple/array) to a QColor."""
3647
if colour is None:
@@ -619,12 +630,7 @@ def apply_stratigraphic_age_to_layer(self, layer, field_name, ramp_name=None):
619630
)
620631
return False
621632

622-
style = QgsStyle().defaultStyle()
623-
ramp = style.colorRamp(ramp_name) if ramp_name else None
624-
if ramp is None:
625-
ramp_names = style.colorRampNames()
626-
if ramp_names:
627-
ramp = style.colorRamp(ramp_names[0])
633+
ramp = _lookup_colour_ramp(ramp_name)
628634

629635
n = len(unique_values)
630636
ranges = []
@@ -642,6 +648,114 @@ def apply_stratigraphic_age_to_layer(self, layer, field_name, ramp_name=None):
642648
)
643649
return True
644650

651+
def apply_stratigraphic_thickness_to_layer(self, layer, field_name, ramp_name=None):
652+
"""Write each unit's thickness onto a layer and style it with a graduated colour ramp.
653+
654+
Writes a 'strat_thickness' field to ``layer`` holding the thickness
655+
set for the matching unit in the stratigraphic column (matched via
656+
``field_name``), then applies a graduated renderer over that field.
657+
Features that do not match a unit are left empty.
658+
659+
Parameters
660+
----------
661+
layer : QgsVectorLayer
662+
The layer to update (e.g. the geological units/geology layer).
663+
field_name : str
664+
Name of the field on ``layer`` holding the stratigraphic unit name.
665+
ramp_name : str, optional
666+
Name of a QGIS colour ramp (from QgsStyle) to use for the
667+
graduated renderer. Falls back to any available ramp if not found.
668+
669+
Returns
670+
-------
671+
bool
672+
True if the field was written and the renderer applied, False otherwise.
673+
"""
674+
if layer is None or not field_name:
675+
self.logger(
676+
message="No layer/unit name field set, cannot apply stratigraphic thickness."
677+
)
678+
return False
679+
if layer.fields().indexFromName(field_name) < 0:
680+
self.logger(message=f"Field '{field_name}' not found on layer '{layer.name()}'.")
681+
return False
682+
683+
thicknesses = {}
684+
for unit in self._stratigraphic_column.order:
685+
if unit.element_type != StratigraphicColumnElementType.UNIT:
686+
continue
687+
if unit.thickness is None:
688+
continue
689+
thicknesses[unit.name] = float(unit.thickness)
690+
if not thicknesses:
691+
self.logger(
692+
message="Stratigraphic column has no units, cannot apply stratigraphic thickness."
693+
)
694+
return False
695+
696+
thickness_field_name = "strat_thickness"
697+
try:
698+
from qgis.core import QgsField
699+
700+
from loopstructural.gui.compatibility import QVariantCompat
701+
702+
layer.startEditing()
703+
if layer.fields().indexFromName(thickness_field_name) < 0:
704+
layer.dataProvider().addAttributes(
705+
[QgsField(thickness_field_name, QVariantCompat.Double)]
706+
)
707+
layer.updateFields()
708+
thickness_index = layer.fields().indexFromName(thickness_field_name)
709+
for feature in layer.getFeatures():
710+
value = feature[field_name]
711+
unit_name = None if value is None else str(value).strip()
712+
layer.changeAttributeValue(
713+
feature.id(), thickness_index, thicknesses.get(unit_name)
714+
)
715+
if not layer.commitChanges():
716+
raise RuntimeError("; ".join(layer.commitErrors()))
717+
except Exception as err:
718+
layer.rollBack()
719+
self.logger(message=f"Failed to write stratigraphic thickness onto layer: {err}")
720+
return False
721+
722+
unique_values = set()
723+
for feature in layer.getFeatures():
724+
value = feature[thickness_field_name]
725+
if value is None or (hasattr(value, 'isNull') and value.isNull()):
726+
continue
727+
unique_values.add(float(value))
728+
unique_values = sorted(unique_values)
729+
730+
if not unique_values:
731+
self.logger(
732+
message="No features matched a stratigraphic unit, cannot style layer by thickness."
733+
)
734+
return False
735+
736+
ramp = _lookup_colour_ramp(ramp_name)
737+
738+
# One class per thickness value, with the class limits half-way
739+
# between adjacent values so that each value is in only one class.
740+
minimum = unique_values[0]
741+
span = unique_values[-1] - minimum
742+
ranges = []
743+
for i, value in enumerate(unique_values):
744+
lower = value if i == 0 else (unique_values[i - 1] + value) / 2
745+
upper = value if i == len(unique_values) - 1 else (value + unique_values[i + 1]) / 2
746+
symbol = QgsSymbol.defaultSymbol(layer.geometryType())
747+
if ramp is not None:
748+
symbol.setColor(ramp.color((value - minimum) / span if span > 0 else 0))
749+
ranges.append(QgsRendererRange(lower, upper, symbol, f"{value:g}"))
750+
751+
layer.setRenderer(QgsGraduatedSymbolRenderer(thickness_field_name, ranges))
752+
layer.triggerRepaint()
753+
self.logger(
754+
message=f"Applied stratigraphic thickness field '{thickness_field_name}' and "
755+
f"graduated styling to layer '{layer.name()}'."
756+
)
757+
return True
758+
645759
def get_stratigraphic_unit_names(self):
646760
"""Get the names of the stratigraphic units in the column, youngest first.
647761

0 commit comments

Comments
 (0)