"""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()