Skip to content

Commit

Permalink
HAI-3353 Add map of all areas to haittojenhallintasuunnitelma PDF (#916)
Browse files Browse the repository at this point in the history
Add a map with all tyoalueet near the top of the PDF. Other maps for the
actual haittojenhallintasuunnietlma will be added later.

The base map is fetched from Helsinki Karttapalvelu, by calling their
Web Map Service with GeoTools. The areas a rendered on top as polygons,
also using GeoTools for the rendering.
  • Loading branch information
corvidian authored Jan 9, 2025
1 parent f9fd537 commit 70c2794
Show file tree
Hide file tree
Showing 23 changed files with 2,376 additions and 100 deletions.
13 changes: 12 additions & 1 deletion services/hanke-service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ group = "fi.hel.haitaton"
version = "0.0.1-SNAPSHOT"

val sentryVersion = "7.19.0"
val geoToolsVersion = "32.1"

repositories { mavenCentral() }
repositories {
mavenCentral().content { excludeModule("javax.media", "jai_core") }
maven { url = uri("https://repo.osgeo.org/repository/release/") }
maven { url = uri("https://maven.geotoolkit.org") }
}

sourceSets {
create("integrationTest") {
Expand Down Expand Up @@ -107,6 +112,12 @@ dependencies {
implementation("com.github.librepdf:openpdf:2.0.3")
implementation("org.apache.xmlgraphics:fop:2.10")

// Geotools
implementation("org.geotools:gt-wms:$geoToolsVersion")
implementation("org.geotools:gt-brewer:$geoToolsVersion")
implementation("org.geotools:gt-epsg-hsql:$geoToolsVersion")
implementation("org.locationtech.jts.io:jts-io-common:1.19.0")

// Testcontainers
testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4"))
testImplementation("org.springframework.boot:spring-boot-testcontainers")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class HakemusService(
private val paatosService: PaatosService,
private val applicationEventPublisher: ApplicationEventPublisher,
private val tormaystarkasteluLaskentaService: TormaystarkasteluLaskentaService,
private val haittojenhallintasuunnitelmaPdfEncoder: HaittojenhallintasuunnitelmaPdfEncoder,
) {

@Transactional(readOnly = true)
Expand Down Expand Up @@ -714,7 +715,7 @@ class HakemusService(

val totalArea =
geometriatDao.calculateCombinedArea(data.areas?.flatMap { it.geometries() } ?: listOf())
val pdfData = HaittojenhallintasuunnitelmaPdfEncoder.createPdf(hanke, data, totalArea)
val pdfData = haittojenhallintasuunnitelmaPdfEncoder.createPdf(hanke, data, totalArea)
val attachmentMetadata =
AttachmentMetadata(
id = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
package fi.hel.haitaton.hanke.pdf

import com.lowagie.text.Chunk
import com.lowagie.text.Document
import com.lowagie.text.Image
import com.lowagie.text.ImgTemplate
import com.lowagie.text.Paragraph
import fi.hel.haitaton.hanke.domain.Hanke
import fi.hel.haitaton.hanke.domain.SavedHankealue
import fi.hel.haitaton.hanke.hakemus.KaivuilmoitusAlue
import fi.hel.haitaton.hanke.hakemus.KaivuilmoitusData
import java.time.ZonedDateTime
import org.springframework.stereotype.Component

object HaittojenhallintasuunnitelmaPdfEncoder {
@Component
class HaittojenhallintasuunnitelmaPdfEncoder(private val mapGenerator: MapGenerator) {

fun createPdf(hanke: Hanke, data: KaivuilmoitusData, totalArea: Float?): ByteArray =
createDocument { document, writer ->
val locationIcon = loadLocationIcon(writer)
formatKaivuilmoitusPdf(document, hanke, data, totalArea, locationIcon)
formatPdf(document, hanke, data, totalArea, locationIcon)
}

private fun formatKaivuilmoitusPdf(
private fun formatPdf(
document: Document,
hanke: Hanke,
data: KaivuilmoitusData,
Expand All @@ -30,7 +37,12 @@ object HaittojenhallintasuunnitelmaPdfEncoder {
document.subtitle(data.name)

document.mapHeader("Alueiden sijainti", locationIcon)
document.placeholderImage()
document.map(data.areas!!, hanke.alueet)

val spacer = Paragraph(Chunk.NEWLINE)
spacer.spacingBefore = 1f
spacer.spacingAfter = pxToPt(-16)
document.add(spacer)

document.section("Perustiedot") {
row("Työn nimi", data.name)
Expand All @@ -53,4 +65,18 @@ object HaittojenhallintasuunnitelmaPdfEncoder {
row("Työn loppupäivämäärä", data.endTime.format())
}
}

fun Document.map(areas: List<KaivuilmoitusAlue>, hankealueet: List<SavedHankealue>) {
// The image is better quality, if it's rendered at a higher resolution and then scaled down
// to fit in the PDF layout.
val bytes = mapGenerator.mapWithAreas(areas, hankealueet, MAP_WIDTH * 2, MAP_HEIGHT * 2)
val image = Image.getInstance(bytes)
image.scaleToFit(pxToPt(MAP_WIDTH), pxToPt(MAP_HEIGHT))
this.add(image)
}

companion object {
const val MAP_WIDTH = 1110
const val MAP_HEIGHT = 400
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package fi.hel.haitaton.hanke.pdf

import java.util.Locale
import kotlin.math.max
import org.geotools.api.referencing.crs.CoordinateReferenceSystem
import org.geotools.geometry.jts.ReferencedEnvelope
import org.geotools.referencing.CRS

data class Point(val x: Double, val y: Double) {

private fun fixedPoint(a: Double): String = String.format(Locale.UK, "%.3f", a)

override fun toString(): String = "(x=${fixedPoint(x)}, y=${fixedPoint(y)})"

companion object {
fun center(a: Point, b: Point) = Point(y = (a.y + b.y) / 2.0, x = (a.x + b.x) / 2.0)
}
}

data class MapBounds(val min: Point, val max: Point) {
val xSize: Double = max.x - min.x
val ySize: Double = max.y - min.y
val center: Point = Point.center(min, max)

fun padded(): MapBounds {
val xPadding = max(xSize * PADDING_RATIO, MIN_PADDING)
val yPadding = max(ySize * PADDING_RATIO, MIN_PADDING)

return MapBounds(
Point(y = min.y - yPadding, x = min.x - xPadding),
Point(y = max.y + yPadding, x = max.x + xPadding),
)
}

fun fitToImage(imageWidth: Int, imageHeight: Int): MapBounds {
val metersPerPixel = max(xSize / imageWidth, ySize / imageHeight)

val newXSize = imageWidth * metersPerPixel
val newYSize = imageHeight * metersPerPixel

return MapBounds(
Point(y = center.y - newYSize / 2, x = center.x - newXSize / 2),
Point(y = center.y + newYSize / 2, x = center.x + newXSize / 2),
)
}

fun squaredOff(): MapBounds {
val maxSize = max(xSize, ySize)

return MapBounds(
Point(y = center.y - maxSize / 2, x = center.x - maxSize / 2),
Point(y = center.y + maxSize / 2, x = center.x + maxSize / 2),
)
}

fun asReferencedEnvelope() = ReferencedEnvelope(min.y, max.y, min.x, max.x, sourceCRS)

companion object {
const val MIN_PADDING = 30.0
const val PADDING_RATIO = 0.1

val sourceCRS: CoordinateReferenceSystem = CRS.decode("EPSG:3879")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package fi.hel.haitaton.hanke.pdf

import fi.hel.haitaton.hanke.SRID
import fi.hel.haitaton.hanke.domain.SavedHankealue
import fi.hel.haitaton.hanke.hakemus.KaivuilmoitusAlue
import fi.hel.haitaton.hanke.toJsonString
import fi.hel.haitaton.hanke.tormaystarkastelu.TormaystarkasteluTulos
import java.awt.Color
import java.awt.Rectangle
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
import kotlin.math.max
import org.geojson.Crs
import org.geojson.Polygon as JsonPolygon
import org.geotools.api.feature.simple.SimpleFeature
import org.geotools.api.feature.simple.SimpleFeatureType
import org.geotools.api.style.Style
import org.geotools.brewer.styling.builder.PolygonSymbolizerBuilder
import org.geotools.data.DataUtilities
import org.geotools.data.simple.SimpleFeatureCollection
import org.geotools.feature.simple.SimpleFeatureBuilder
import org.geotools.map.FeatureLayer
import org.geotools.map.MapContent
import org.geotools.ows.wms.WMSUtils
import org.geotools.ows.wms.WebMapServer
import org.geotools.ows.wms.map.WMSLayer
import org.geotools.renderer.GTRenderer
import org.geotools.renderer.lite.StreamingRenderer
import org.locationtech.jts.geom.CoordinateFilter
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.geojson.GeoJsonReader
import org.springframework.stereotype.Component

@Component
class MapGenerator(private val wms: WebMapServer) {

fun mapWithAreas(
areas: List<KaivuilmoitusAlue>,
hankealueet: List<SavedHankealue>,
imageWidth: Int,
imageHeight: Int,
): ByteArray {
val bounds = calculateBounds(areas, imageWidth, imageHeight)

val layer =
WMSUtils.getNamedLayers(wms.capabilities).find {
it.title == KIINTEISTOKARTTA_LAYER_TITLE
}!!

val mapLayer = WMSLayer(wms, layer, KIINTEISTOKARTTA_STYLE, "image/png")

val map = MapContent()
map.addLayer(mapLayer)

val hankealueIds = areas.map { it.hankealueId }.toSet()
hankealueet
.filter { hankealueIds.contains(it.id) }
.forEach { alue ->
val polygons =
alue.geometriat
?.featureCollection
?.features
?.map { it.geometry }
?.filterIsInstance<JsonPolygon>() ?: listOf()

val style = selectColorStyle(alue.tormaystarkasteluTulos)
val featureLayer = FeatureLayer(readPolygons(polygons), style)
map.addLayer(featureLayer)
}

areas
.flatMap { it.tyoalueet }
.forEach {
val style = selectColorStyle(it.tormaystarkasteluTulos)
val featureLayer = FeatureLayer(readPolygon(it.geometry), style)
map.addLayer(featureLayer)
}

val renderer: GTRenderer = StreamingRenderer()
renderer.mapContent = map

val envelope = bounds.asReferencedEnvelope()

// The maps are distorted, unless we request a square map. So we request a square and then
// crop it to fit the requested image dimensions.
val imageSize = max(imageWidth, imageHeight)
val imageBounds = Rectangle(0, 0, imageSize, imageSize)
val image = BufferedImage(imageBounds.width, imageBounds.height, BufferedImage.TYPE_INT_RGB)

val gr = image.createGraphics()
gr.paint = Color.WHITE
gr.fill(imageBounds)

val cropped =
image.getSubimage(
(imageSize - imageWidth) / 2,
(imageSize - imageHeight) / 2,
imageWidth,
imageHeight,
)

renderer.paint(gr, imageBounds, envelope)
val baos = ByteArrayOutputStream()
ImageIO.write(cropped, "png", baos)
map.dispose()
return baos.toByteArray()
}

companion object {
const val KIINTEISTOKARTTA_LAYER_TITLE = "Kiinteistokartan_maastotiedot"
const val KIINTEISTOKARTTA_STYLE = "default-style-avoindata:Kiinteistokartan_maastotiedot"

private val polygonType: SimpleFeatureType =
DataUtilities.createType("Polygon", "the_geom:Polygon:srid=$SRID")

fun calculateBounds(
areas: List<KaivuilmoitusAlue>,
imageWidth: Int,
imageHeight: Int,
): MapBounds {
val allCoords = areas.flatMap { it.geometries() }.flatMap { it.coordinates.flatten() }

val latitudes = allCoords.map { it.latitude }
val longitudes = allCoords.map { it.longitude }
val bounds =
MapBounds(
Point(y = latitudes.min(), x = longitudes.min()),
Point(y = latitudes.max(), x = longitudes.max()),
)

return bounds.padded().fitToImage(imageWidth, imageHeight).squaredOff()
}

fun selectColorStyle(tormaystarkasteluTulos: TormaystarkasteluTulos?): Style =
NuisanceColor.selectColor(tormaystarkasteluTulos).style

private fun readPolygon(polygon: JsonPolygon): SimpleFeatureCollection =
readPolygons(listOf(polygon))

private fun readPolygons(polygons: List<JsonPolygon>): SimpleFeatureCollection {
val reader = GeoJsonReader()

val features: MutableList<SimpleFeature> = mutableListOf()

polygons.forEach { polygon ->
val copy = JsonPolygon()
copy.exteriorRing = polygon.exteriorRing
copy.interiorRings.addAll(polygon.interiorRings)
copy.crs = Crs().apply { properties = mapOf(Pair("name", "EPSG:$SRID")) }
val geometry = reader.read(copy.toJsonString()) as Polygon

// I'm not quite sure why, but the x and y coordinates seem to be reversed in
// different
// implementations. Currently, the maps are drawn correctly, so I don't want to mess
// with them. The coordinates are reversed here and when calculating the final map
// boundaries for the image (the referenced envelope).
geometry.apply(
CoordinateFilter { coordinate ->
val oldX = coordinate.x
coordinate.x = coordinate.y
coordinate.y = oldX
}
)
val featureBuilder = SimpleFeatureBuilder(polygonType)
featureBuilder.add(geometry)
features.add(featureBuilder.buildFeature(null))
}

return DataUtilities.collection(features)
}

fun buildAreaStyle(color: Color): Style {
val builder = PolygonSymbolizerBuilder()
builder.stroke().color(Color.BLACK).width(4.0)
builder.fill().color(color).opacity(0.6)
return builder.buildStyle()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package fi.hel.haitaton.hanke.pdf

import fi.hel.haitaton.hanke.tormaystarkastelu.TormaystarkasteluTulos
import java.awt.Color
import org.geotools.api.style.Style

enum class NuisanceColor(val color: Color) {
BLUE(Color(0, 98, 185)),
GRAY(Color(176, 184, 191)),
GREEN(Color(0, 146, 70)),
YELLOW(Color(255, 218, 7)),
RED(Color(196, 18, 62));

val style: Style by lazy { MapGenerator.buildAreaStyle(color) }

companion object {
fun selectColor(tormaystarkasteluTulos: TormaystarkasteluTulos?): NuisanceColor {
val maxHaitta = tormaystarkasteluTulos?.liikennehaittaindeksi?.indeksi

return when {
maxHaitta == null -> BLUE
maxHaitta.isNaN() -> BLUE
maxHaitta < 0f -> BLUE
maxHaitta == 0f -> GRAY
maxHaitta < 3f -> GREEN
maxHaitta < 4f -> YELLOW
else -> RED
}
}
}
}
Loading

0 comments on commit 70c2794

Please sign in to comment.