File indexing completed on 2024-05-19 04:22:32
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()