File indexing completed on 2024-04-28 15:39:09

0001 #!/usr/bin/env python
0002 
0003 # SPDX-FileCopyrightText: 2021-2023 Isaac Wismer <isaac@iwismer.ca>
0004 #
0005 # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0006 
0007 """
0008     This script takes a timezone data file (designed for the ones provided
0009     here: https://github.com/evansiroky/timezone-boundary-builder/), and turns it
0010     into a PNG with each of the timezones a unique color, and a JSON file with a
0011     mapping between the color and timezone.
0012 
0013     This script can be run with the following command:
0014     python timezone-png-creator <path-to-datafile> [output-dir]
0015     the --height flag can be used to change the height of the image.
0016 
0017     This script requires QGIS to be installed on the machine, and currently only
0018     works on Linux, but with a few small tweaks could work on Windows as well.
0019 """
0020 
0021 import argparse
0022 import tempfile
0023 from pathlib import Path
0024 from typing import List
0025 import re
0026 from hashlib import sha1
0027 import sys
0028 
0029 from PyQt5.QtCore import QSize, Qt
0030 from PyQt5.QtGui import QColor
0031 from qgis.core import (
0032     QgsApplication,
0033     QgsCategorizedSymbolRenderer,
0034     QgsMapRendererParallelJob,
0035     QgsMapSettings,
0036     QgsRendererCategory,
0037     QgsSimpleFillSymbolLayer,
0038     QgsSymbol,
0039     QgsVectorLayer,
0040 )
0041 from qgis.PyQt.QtCore import QEventLoop
0042 
0043 # Initialize QGis
0044 qgs = QgsApplication([], False)
0045 QgsApplication.setPrefixPath("/usr", True)
0046 QgsApplication.initQgis()
0047 
0048 def stylize_map(layer: QgsVectorLayer) -> [List[str], List[str]]:
0049     """Stylize the layer with unique colors per timezone
0050 
0051     Args:
0052         layer (QgsVectorLayer): The layer to stylize
0053 
0054     Returns:
0055         [List[str], List[str]]: A list with all timezone ids and one with the respective color
0056     """
0057 
0058     print("Reading timezones from file")
0059     timezones = layer.uniqueValues(layer.fields().indexOf("tzid"))
0060     timezones = list(timezones)
0061     timezones.sort()
0062 
0063     categorized_renderer = QgsCategorizedSymbolRenderer()
0064 
0065     print("Stylizing map")
0066 
0067     timezone_ids = []
0068     timezone_colors = []
0069     features = layer.getFeatures()
0070     categories = []
0071     usedColors = []
0072 
0073     for tz in timezones:
0074         # Modify the Etc timezones to match the Qt format
0075 
0076         qt_tz = tz
0077 
0078         # There are a few exceptions where the Qt timezone ids differ from the dataset ids:
0079         match = re.match(r"Etc/GMT([+-])(\d+)", tz)
0080         if match:
0081             qt_tz = f"UTC{match.group(1)}{match.group(2):0>2}:00"
0082         elif tz == "Etc/UTC":
0083             qt_tz = "UTC"
0084         elif tz == "Etc/GMT":
0085             qt_tz = "UTC+00:00"
0086 
0087         # Derive a color from the timezone's name
0088 
0089         hex = sha1(qt_tz.encode("utf-8")).hexdigest()[0:6]
0090         if hex in usedColors:
0091             # This is very unlikely if not impossible to happen, but who knows?!
0092             print("Timezone {} caused a color collision! Please review this script!".format(qt_tz))
0093             sys.exit(1)
0094         usedColors.append(hex)
0095 
0096         rh = hex[0:2]
0097         gh = hex[2:4]
0098         bh = hex[4:6]
0099 
0100         r = int(rh, 16)
0101         g = int(gh, 16)
0102         b = int(bh, 16)
0103 
0104         # Add it to the mapping
0105         timezone_ids.append(qt_tz)
0106         timezone_colors.append(f"#{rh:0>2}{gh:0>2}{bh:0>2}")
0107 
0108         symbol = QgsSymbol.defaultSymbol(layer.geometryType())
0109         symbol_layer = QgsSimpleFillSymbolLayer.create({"color": f"{r}, {g}, {b}"})
0110         symbol_layer.setStrokeWidth(0.0)
0111         symbol_layer.setStrokeStyle(Qt.PenStyle.NoPen)
0112         symbol.changeSymbolLayer(0, symbol_layer)
0113 
0114         category = QgsRendererCategory(tz, symbol, tz)
0115         categories.append(category)
0116 
0117     renderer = QgsCategorizedSymbolRenderer("tzid", categories)
0118     layer.setRenderer(renderer)
0119     layer.triggerRepaint()
0120 
0121     return timezone_ids, timezone_colors
0122 
0123 def export_data(layer: QgsVectorLayer, timezone_ids: List[str], timezone_colors: List[str],
0124                 path: Path, image_height: int) -> None:
0125     """Saves the image and mapping file
0126 
0127     Args:
0128         layer (QgsVectorLayer): The layer to save
0129         timezone_ids (List[str]): A list of all timezone ids
0130         timezone_colors (List[str]): A list of all timezone colors
0131         path (Path): The folder to save the data to
0132         image_height (int): The height of the image to save
0133     """
0134 
0135     path.mkdir(parents=True, exist_ok=True)
0136 
0137     # We write the JSON dataset by hand, so that the order of all key -> value mappings inside the
0138     # file is consistent. Using JSON functions, the dictionary would be written to the file in a
0139     # random order, making content versioning hard as the file would completely change each time
0140     # it is generated.
0141     json_file = (path / "timezones.json").resolve()
0142     print(f"Saving mappings JSON file to: {json_file.absolute()}")
0143     with open(json_file, "w") as f:
0144         f.write("{\n")
0145         last = len(timezone_ids)
0146         for i in range(0, last):
0147             f.write("\"{}\": \"{}\"".format(timezone_colors[i],  timezone_ids[i]))
0148             if i < last - 1:
0149                 f.write(",")
0150             f.write("\n")
0151         f.write("}\n")
0152 
0153     png_file = (path / "timezones.png").resolve()
0154     print(f"Saving PNG map to: {png_file.absolute()}")
0155     settings = QgsMapSettings()
0156     settings.setLayers([layer])
0157     settings.setBackgroundColor(QColor(255, 255, 255))
0158     settings.setOutputSize(QSize(image_height * 2, image_height))
0159     settings.setExtent(layer.extent())
0160     settings.setFlag(QgsMapSettings.Antialiasing, False)
0161 
0162     def finished() -> None:
0163         """Function to save the rendered map once it is done rendering"""
0164         img = render.renderedImage()
0165         img.save(str(png_file), "png")
0166 
0167     render = QgsMapRendererParallelJob(settings)
0168     render.finished.connect(finished)
0169     render.start()
0170 
0171     # This ensures that the program doesn't exit before the image is saved
0172     loop = QEventLoop()
0173     render.finished.connect(loop.quit)
0174     loop.exec_()
0175 
0176 def main():
0177     parser = argparse.ArgumentParser()
0178     parser.add_argument("--shapefile",
0179                         type = Path,
0180                         help = "The timezone data shapefile (.shp, defaults to "
0181                                "combined-shapefile-with-oceans.shp)",
0182                         default = "combined-shapefile-with-oceans.shp")
0183     parser.add_argument("--outdir",
0184                         type = Path,
0185                         help = "The folder to place the output data files in (defaults to .)",
0186                         default = ".")
0187     parser.add_argument("--height",
0188                         type = int,
0189                         help = "The height of the output image. Should be an even number "
0190                                "(defaults to 2000)",
0191                         default = 2000)
0192     args = vars(parser.parse_args())
0193 
0194     print(f"Opening data file: {args['shapefile'].absolute().resolve()}")
0195     layer = QgsVectorLayer(str(args["shapefile"]))
0196     timezone_ids, timezone_colors = stylize_map(layer)
0197     export_data(layer, timezone_ids, timezone_colors, args["outdir"], args["height"])
0198 
0199 if __name__ == "__main__":
0200     main()