"""Style the A417 boreholes in QGIS: categorised symbols, labels, an HTML map tip and a saved project.
Part 2 of the TechnicalGIS series "AGS data in GIS". Run boreholes_map.py first, then run this with
the QGIS Python launcher, for example on Windows:
"C:\\Program Files\\QGIS 3.40.12\\bin\\python-qgis-ltr.bat" qgis_borehole_map.py
It writes output/a417_boreholes.qgz (open it in QGIS Desktop) and renders output/04-qgis-borehole-map.png.
Tested with QGIS 3.40.12-Bratislava.
Data: A417 Missing Link ground investigation (BGS deposited data item 173984), Open Government Licence
v3.0, contains British Geological Survey materials © UKRI 2026. Environment Agency LIDAR Composite DTM
(Open Government Licence v3).
"""
from pathlib import Path
from qgis.core import (
Qgis,
QgsApplication,
QgsCategorizedSymbolRenderer,
QgsCoordinateReferenceSystem,
QgsMapRendererParallelJob,
QgsMapSettings,
QgsMarkerSymbol,
QgsPalLayerSettings,
QgsProject,
QgsRasterLayer,
QgsRectangle,
QgsRendererCategory,
QgsTextBufferSettings,
QgsTextFormat,
QgsVectorLayer,
QgsVectorLayerSimpleLabeling,
)
from qgis.PyQt.QtCore import QPoint, QSize, Qt
from qgis.PyQt.QtGui import QColor, QFont, QPainter
OUT = Path(__file__).parent / "output"
CREDIT = ("Contains Environment Agency information © Environment Agency and database right; "
"BGS © UKRI (OGL v3)")
# Same colours as boreholes_map.py
TYPE_COLOURS = {"RC": "#1f78b4", "TP": "#e6550d", "CPT": "#31a354", "RO": "#756bb1", "CP": "#d62728"}
# The map tip: a header from LOCA, a few summary fields, then the hole's log as the HTML table
# that boreholes_map.py stored in the log_html field.
MAP_TIP = """
[% "LOCA_ID" %] · [% "type_name" %]
Ground level [% format_number("LOCA_GL", 2) %] m OD (LIDAR [% format_number("lidar_gl", 2) %])
Final depth [% format_number("LOCA_FDEP", 2) %] m
[% CASE WHEN "rockhead_depth" IS NOT NULL
THEN '· rock at ' || format_number("rockhead_depth", 2) || ' m (' || "rockhead_name" || ')' END %]
[% CASE WHEN "first_water_strike" IS NOT NULL
THEN 'First water strike ' || format_number("first_water_strike", 2) || ' m
' END %]
[% "n_samples" %] samples, [% "n_spt" %] SPTs
[% "log_html" %]"""
def main() -> None:
qgs = QgsApplication([], False)
qgs.initQgis()
print("QGIS", Qgis.version())
project = QgsProject.instance()
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
shade = QgsRasterLayer(str(OUT / "a417_hillshade.tif"), "LIDAR hillshade")
holes = QgsVectorLayer(f"{OUT / 'a417_boreholes.gpkg'}|layername=boreholes", "A417 exploratory holes", "ogr")
print(f"hillshade valid={shade.isValid()} | boreholes valid={holes.isValid()}, "
f"{holes.featureCount()} features, CRS {holes.crs().authid()}")
# One symbol per LOCA_TYPE, labelled with the description from the ABBR group.
names = {f["LOCA_TYPE"]: f["type_name"] for f in holes.getFeatures()}
categories = []
for code, colour in TYPE_COLOURS.items():
symbol = QgsMarkerSymbol.createSimple({"name": "circle", "color": colour, "outline_color": "white",
"outline_width": "0.3", "size": "2.6"})
categories.append(QgsRendererCategory(code, symbol, f"{code}: {names.get(code, code)}"))
holes.setRenderer(QgsCategorizedSymbolRenderer("LOCA_TYPE", categories))
# Label every hole with its LOCA_ID, haloed so it reads over the hillshade.
fmt = QgsTextFormat()
fmt.setFont(QFont("Arial"))
fmt.setSize(7)
buffer = QgsTextBufferSettings()
buffer.setEnabled(True)
buffer.setSize(0.8)
buffer.setColor(QColor("white"))
fmt.setBuffer(buffer)
label = QgsPalLayerSettings()
label.fieldName = "LOCA_ID"
label.placement = Qgis.LabelPlacement.AroundPoint
label.setFormat(fmt)
holes.setLabeling(QgsVectorLayerSimpleLabeling(label))
holes.setLabelsEnabled(True)
holes.setDisplayExpression('"LOCA_ID"')
holes.setMapTipTemplate(MAP_TIP)
project.addMapLayers([shade, holes], False)
root = project.layerTreeRoot()
root.addLayer(holes) # first in the tree draws on top
root.addLayer(shade)
# Credit the data on every map: as layer attribution, and as QGIS Desktop's copyright decoration
# (View > Decorations > Copyright Label), which the project remembers.
shade.serverProperties().setAttribution("© Environment Agency, Open Government Licence v3")
holes.serverProperties().setAttribution("Contains British Geological Survey materials © UKRI, Open Government Licence v3")
project.writeEntry("CopyrightLabel", "/Enabled", True)
project.writeEntry("CopyrightLabel", "/Label", CREDIT)
project.write(str(OUT / "a417_boreholes.qgz"))
print("saved output/a417_boreholes.qgz with the style, labels and map tip")
# Render the busiest part of the site, the top of Crickley Hill, at a scale where labels fit.
settings = QgsMapSettings()
settings.setLayers([holes, shade])
settings.setDestinationCrs(project.crs())
settings.setExtent(QgsRectangle(392_700, 215_500, 393_900, 216_500))
settings.setOutputSize(QSize(1500, 1250))
settings.setOutputDpi(150)
settings.setBackgroundColor(QColor("white"))
job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
image = job.renderedImage()
painter = QPainter(image)
painter.setFont(QFont("Arial", 7))
box = painter.fontMetrics().boundingRect(CREDIT).adjusted(-6, -4, 6, 4)
box.moveBottomLeft(QPoint(8, image.height() - 8))
painter.fillRect(box, QColor(255, 255, 255, 220))
painter.setPen(QColor("#333333"))
painter.drawText(box, Qt.AlignCenter, CREDIT)
painter.end()
image.save(str(OUT / "04-qgis-borehole-map.png"))
print(f"rendered 04-qgis-borehole-map.png at 1:{settings.scale():,.0f}; render errors: {len(job.errors())}")
qgs.exitQgis()
if __name__ == "__main__":
main()