diff --git a/examples/world.jGIS b/examples/world.jGIS index a66009241..89b6c2e9d 100644 --- a/examples/world.jGIS +++ b/examples/world.jGIS @@ -8,190 +8,87 @@ "parameters": { "color": { "fill-color": [ - "case", + "interpolate", [ - "==", - [ - "get", - "POP_RANK" - ], - 1.0 - ], - [ - 255.0, - 0.0, - 255.0, - 1.0 - ], - [ - "==", - [ - "get", - "POP_RANK" - ], - 4.0 - ], - [ - 255.0, - 23.0, - 232.0, - 1.0 - ], - [ - "==", - [ - "get", - "POP_RANK" - ], - 8.0 - ], - [ - 255.0, - 46.0, - 209.0, - 1.0 - ], - [ - "==", - [ - "get", - "POP_RANK" - ], - 10.0 + "linear" ], [ - 255.0, - 70.0, - 185.0, - 1.0 - ], - [ - "==", - [ - "get", - "POP_RANK" - ], - 11.0 + "get", + "POP_RANK" ], + 2.888888888888889, [ - 255.0, - 93.0, - 162.0, + 125.0, + 0.0, + 179.0, 1.0 ], + 4.777777777777778, [ - "==", - [ - "get", - "POP_RANK" - ], - 12.0 - ], - [ - 255.0, 116.0, - 139.0, + 0.0, + 218.0, 1.0 ], + 6.666666666666666, [ - "==", - [ - "get", - "POP_RANK" - ], - 13.0 - ], - [ - 255.0, - 139.0, - 116.0, + 98.0, + 74.0, + 237.0, 1.0 ], + 8.555555555555555, [ - "==", - [ - "get", - "POP_RANK" - ], - 14.0 - ], - [ - 255.0, - 162.0, - 93.0, + 68.0, + 146.0, + 231.0, 1.0 ], + 10.444444444444445, [ - "==", - [ - "get", - "POP_RANK" - ], - 15.0 - ], - [ - 255.0, - 185.0, - 70.0, + 0.0, + 204.0, + 197.0, 1.0 ], + 12.333333333333334, [ - "==", - [ - "get", - "POP_RANK" - ], - 16.0 + 0.0, + 247.0, + 146.0, + 1.0 ], + 14.222222222222223, [ + 0.0, 255.0, - 209.0, - 46.0, + 88.0, 1.0 ], + 16.11111111111111, [ - "==", - [ - "get", - "POP_RANK" - ], - 17.0 - ], - [ + 40.0, 255.0, - 232.0, - 23.0, + 8.0, 1.0 ], + 18.0, [ - "==", - [ - "get", - "POP_RANK" - ], - 18.0 - ], - [ - 255.0, + 147.0, 255.0, 0.0, 1.0 - ], - [ - 0.0, - 0.0, - 0.0, - 0.0 ] ] }, "opacity": 1.0, "source": "b4287bea-e217-443c-b527-58f7559c824c", "symbologyState": { - "colorRamp": "spring", - "mode": "", - "nClasses": "", - "renderType": "Categorized", + "colorRamp": "cool", + "method": "color", + "mode": "equal interval", + "nClasses": "9", + "renderType": "Graduated", "value": "POP_RANK" }, "type": "fill" @@ -204,16 +101,16 @@ "options": { "bearing": 0.0, "extent": [ - -3689578.4464635598, - -20037508.34278924, - 14171832.118003651, - 20037508.34278924 + -35938860.79774074, + -17466155.24107265, + 4136155.887837734, + 18813049.299423713 ], - "latitude": 0.0, - "longitude": 47.08184342581217, + "latitude": 6.038467945870664, + "longitude": -142.8442794845441, "pitch": 0.0, "projection": "EPSG:3857", - "zoom": 2.1686721181322306 + "zoom": 2.100662339005199 }, "sources": { "b4287bea-e217-443c-b527-58f7559c824c": { diff --git a/python/jupytergis_qgis/jupytergis_qgis/qgis_loader.py b/python/jupytergis_qgis/jupytergis_qgis/qgis_loader.py index 54137f5e6..1753d2c91 100644 --- a/python/jupytergis_qgis/jupytergis_qgis/qgis_loader.py +++ b/python/jupytergis_qgis/jupytergis_qgis/qgis_loader.py @@ -52,6 +52,27 @@ def closeQgis(): qgs.exitQgis() +def rgb_to_hex(rgb_str): + """Converts an RGB string (comma-separated) to a hex color code.""" + rgb_values = rgb_str.split(",")[:3] + r, g, b = [int(val) for val in rgb_values] + return f"#{r:02x}{g:02x}{b:02x}" + + +def hex_to_rgba(hex_color): + """Convert a hex color to an RGBA tuple.""" + hex_color = hex_color.lstrip("#") + if len(hex_color) == 6: + r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + a = 255 # Default alpha value + elif len(hex_color) == 8: + r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6)) + else: + raise ValueError(f"Invalid hex color: {hex_color}") + + return r, g, b, a + + def qgis_layer_to_jgis( qgis_layer: QgsLayerTreeLayer, layers: dict[str, dict[str, Any]], @@ -166,29 +187,146 @@ def qgis_layer_to_jgis( source_parameters.update(path=file_name) renderer = layer.renderer() - symbol = renderer.symbol() - - # Opacity stuff - opacity = symbol.opacity() - alpha = hex(int(opacity * 255))[2:].zfill(2) + # Default symbol extraction + symbol = None color = {} - if isinstance(symbol, QgsMarkerSymbol): - color["circle-fill-color"] = symbol.color().name() + alpha - color["circle-stroke-color"] = symbol.color().name() + alpha - if isinstance(symbol, QgsLineSymbol): - color["stroke-color"] = symbol.color().name() + alpha + if isinstance(renderer, QgsSingleSymbolRenderer): + symbol = renderer.symbol() + if "symbologyState" not in layer_parameters: + layer_parameters["symbologyState"] = {} + + layer_parameters["symbologyState"]["renderType"] = "Single Symbol" + + elif isinstance(renderer, QgsCategorizedSymbolRenderer): + case_conditions = ["case"] + field_name = renderer.classAttribute() + for category in renderer.categories(): + cat_symbol = category.symbol() + opacity = cat_symbol.opacity() + + category_color = cat_symbol.color().name() + case_conditions.append(["==", ["get", field_name], category.value()]) + r, g, b, a = hex_to_rgba(category_color) + case_conditions.append([r, g, b, a / 255]) + + layer_parameters["symbologyState"] = { + "colorRamp": "cool", + "mode": "", + "nClasses": "", + "renderType": "Categorized", + "value": field_name, + } + case_conditions.append([0.0, 0.0, 0.0, 0.0]) + outline_color_str = ( + cat_symbol.symbolLayer(0).properties().get("outline_color", "0,0,0,255") + ) + if isinstance(cat_symbol, QgsMarkerSymbol): + color["circle-fill-color"] = case_conditions + color["circle-stroke-color"] = rgb_to_hex(outline_color_str) + layer_parameters.update(type="circle") + elif isinstance(cat_symbol, QgsLineSymbol): + color["stroke-color"] = case_conditions + color["stroke-line-cap"] = ( + cat_symbol.symbolLayer(0).properties().get("capstyle") + ) + color["stroke-line-join"] = ( + cat_symbol.symbolLayer(0).properties().get("joinstyle") + ) + color["stroke-width"] = float( + cat_symbol.symbolLayer(0).properties().get("line_width") + ) + layer_parameters.update(type="line") + elif isinstance(cat_symbol, QgsFillSymbol): + color["fill-color"] = case_conditions + color["stroke-color"] = rgb_to_hex(outline_color_str) + layer_parameters.update(type="fill") - if isinstance(symbol, QgsFillSymbol): - color["fill-color"] = symbol.color().name() - color["stroke-color"] = symbol.color().name() + elif isinstance(renderer, QgsGraduatedSymbolRenderer): + field_name = renderer.classAttribute() + interpolate_conditions = ["interpolate", ["linear"], ["get", field_name]] - layer_parameters.update(type="fill") - layer_parameters.update(color=color) + for range in renderer.ranges(): + range_symbol = range.symbol() + opacity = range_symbol.opacity() + alpha = opacity - if isinstance(renderer, QgsSingleSymbolRenderer): - layer_parameters.update(symbologyState={"renderType": "Single Symbol"}) + range_color = range_symbol.color().getRgbF() + r, g, b, _ = range_color + + lower = range.upperValue() + + interpolate_conditions.append(lower) + interpolate_conditions.append([r * 255, g * 255, b * 255, alpha]) + + layer_parameters["symbologyState"] = { + "renderType": "Graduated", + "value": field_name, + } + + # Determine geometry type and apply color interpolation + if isinstance(range_symbol, QgsMarkerSymbol): + color["circle-fill-color"] = interpolate_conditions + color["circle-stroke-color"] = rgb_to_hex( + range_symbol.symbolLayer(0) + .properties() + .get("outline_color", "0,0,0,255") + ) + layer_parameters.update(type="circle") + elif isinstance(range_symbol, QgsLineSymbol): + color["stroke-color"] = interpolate_conditions + color["stroke-line-cap"] = ( + range_symbol.symbolLayer(0).properties().get("capstyle") + ) + color["stroke-line-join"] = ( + range_symbol.symbolLayer(0).properties().get("joinstyle") + ) + color["stroke-width"] = float( + range_symbol.symbolLayer(0).properties().get("line_width") + ) + layer_parameters.update(type="line") + elif isinstance(range_symbol, QgsFillSymbol): + color["fill-color"] = interpolate_conditions + color["stroke-color"] = rgb_to_hex( + range_symbol.symbolLayer(0) + .properties() + .get("outline_color", "0,0,0,255") + ) + layer_parameters.update(type="fill") + + if symbol: + # Opacity handling + opacity = symbol.opacity() + alpha = hex(int(opacity * 255))[2:].zfill(2) + + if isinstance(symbol, QgsMarkerSymbol): + color["circle-fill-color"] = symbol.color().name() + color["circle-stroke-color"] = symbol.color().name() + layer_parameters.update(type="circle") + + elif isinstance(symbol, QgsLineSymbol): + color["stroke-color"] = symbol.color().name() + color["stroke-line-cap"] = ( + symbol.symbolLayer(0).properties().get("capstyle") + ) + color["stroke-line-join"] = ( + symbol.symbolLayer(0).properties().get("joinstyle") + ) + color["stroke-width"] = float( + symbol.symbolLayer(0).properties().get("line_width") + ) + layer_parameters.update(type="line") + + elif isinstance(symbol, QgsFillSymbol): + color["fill-color"] = symbol.color().name() + outline_color_str = ( + symbol.symbolLayer(0).properties().get("outline_color", "0,0,0,255") + ) + color["stroke-color"] = rgb_to_hex(outline_color_str) + layer_parameters.update(type="fill") + + layer_parameters.update(color=color) if isinstance(layer, QgsVectorTileLayer): layer_type = "VectorTileLayer" @@ -398,10 +536,8 @@ def get_base_symbol(geometry_type, color_params, opacity): stroke_width = color_params.get("circle-stroke-width", 1) symbol_layer.setStrokeWidth(stroke_width) elif geometry_type == "line": - stroke_color = QColor(color_params.get("stroke-color", "#000000")) - symbol_layer.setStrokeColor(stroke_color) stroke_width = color_params.get("stroke-width", 1) - symbol_layer.setWidth(stroke_width) + symbol_layer.setWidth(float(stroke_width)) elif geometry_type == "fill": stroke_color = QColor(color_params.get("stroke-color", "#000000")) symbol_layer.setStrokeColor(stroke_color) @@ -415,10 +551,13 @@ def create_categorized_renderer( symbology_state, geometry_type, color_params, base_symbol ): """Creates a categorized renderer.""" - fill_color_rules = color_params.get("circle-fill-color", []) - radius_rules = ( - color_params.get("circle-radius", []) if geometry_type == "circle" else [] - ) + if geometry_type == "circle": + fill_color_rules = color_params.get("circle-fill-color", []) + radius_rules = color_params.get("circle-radius", []) + elif geometry_type == "fill": + fill_color_rules = color_params.get("fill-color", []) + elif geometry_type == "line": + fill_color_rules = color_params.get("stroke-color", []) renderer = QgsCategorizedSymbolRenderer(symbology_state.get("value")) @@ -431,11 +570,21 @@ def create_categorized_renderer( color = [r, g, b, 1.0] category_symbol = base_symbol.clone() - category_symbol.setColor( - QColor(int(color[0]), int(color[1]), int(color[2]), int(color[3] * 255)) - ) + if isinstance(color, str) and color.startswith("#"): + r, g, b, a = hex_to_rgba(color) + elif isinstance(color, list) and len(color) == 4: + r, g, b, a = color + a *= 255 + else: + raise ValueError(f"Unexpected color format: {color}") - if geometry_type == "circle" and len(radius_rules) > i: + category_symbol = base_symbol.clone() + category_symbol.setColor(QColor(int(r), int(g), int(b), int(a))) + + if geometry_type == "circle" and isinstance(radius_rules, (int, float)): + radius = radius_rules + category_symbol.setSize(2 * radius) + elif geometry_type == "circle" and len(radius_rules) > i: radius = radius_rules[i + 3] category_symbol.setSize(2 * radius) @@ -459,7 +608,6 @@ def create_graduated_renderer( ranges = [] previous_value = 0 - last_color = None last_radius = None for i in range(3, len(fill_color_rules) - 2, 2): @@ -469,11 +617,10 @@ def create_graduated_renderer( if isinstance(color, list) and len(color) == 4: r, g, b, a = color - qcolor = QColor(int(r), int(g), int(b), int(a * 255)) - last_color = qcolor + range_color = QColor(int(r), int(g), int(b), int(a * 255)) range_symbol = base_symbol.clone() - range_symbol.setColor(qcolor) + range_symbol.setColor(range_color) if geometry_type == "circle" and len(radius_rules) > i + 1: radius = radius_rules[i + 1] @@ -490,6 +637,12 @@ def create_graduated_renderer( ranges.append(g_range) previous_value = lower_value + if ( + isinstance(fill_color_rules[len(fill_color_rules) - 1], list) + and len(fill_color_rules[len(fill_color_rules) - 1]) == 4 + ): + r, g, b, a = fill_color_rules[len(fill_color_rules) - 1] + last_color = QColor(int(r), int(g), int(b), int(a * 255)) if last_color: final_symbol = base_symbol.clone() final_symbol.setColor(last_color) diff --git a/python/jupytergis_qgis/jupytergis_qgis/tests/test_qgis.py b/python/jupytergis_qgis/jupytergis_qgis/tests/test_qgis.py index ac56a77f2..ccd994dfc 100644 --- a/python/jupytergis_qgis/jupytergis_qgis/tests/test_qgis.py +++ b/python/jupytergis_qgis/jupytergis_qgis/tests/test_qgis.py @@ -125,8 +125,24 @@ def test_qgis_saver(): if os.path.exists(filename): os.remove(filename) - layer_ids = [str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4())] - source_ids = [str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4())] + layer_ids = [ + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + ] + source_ids = [ + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + str(uuid4()), + ] jgis = { "options": { "bearing": 0.0, @@ -199,6 +215,104 @@ def test_qgis_saver(): "type": "VectorLayer", "visible": True, }, + layer_ids[5]: { + "name": "Custom GeoJSON Layer", + "parameters": { + "color": { + "fill-color": [ + "interpolate", + ["linear"], + ["get", "POP_RANK"], + 2.888888888888889, + [125.0, 0.0, 179.0, 1.0], + 4.777777777777778, + [116.0, 0.0, 218.0, 1.0], + 6.666666666666666, + [98.0, 74.0, 237.0, 1.0], + 8.555555555555555, + [68.0, 146.0, 231.0, 1.0], + 10.444444444444445, + [0.0, 204.0, 197.0, 1.0], + 12.333333333333334, + [0.0, 247.0, 146.0, 1.0], + 14.222222222222223, + [0.0, 255.0, 88.0, 1.0], + 16.11111111111111, + [40.0, 255.0, 8.0, 1.0], + 18.0, + [147.0, 255.0, 0.0, 1.0], + ], + "stroke-color": "#000000", + }, + "opacity": 1.0, + "source": source_ids[5], + "symbologyState": { + "renderType": "Graduated", + "value": "POP_RANK", + }, + "type": "fill", + }, + "type": "VectorLayer", + "visible": True, + }, + layer_ids[6]: { + "name": "Custom GeoJSON Layer", + "parameters": { + "color": { + "stroke-color": [ + "case", + ["==", ["get", "min_label"], 6.0], + [125.0, 0.0, 179.0, 1.0], + ["==", ["get", "min_label"], 7.0], + [121.0, 0.0, 199.0, 1.0], + ["==", ["get", "min_label"], 7.4], + [116.0, 0.0, 218.0, 1.0], + ["==", ["get", "min_label"], 7.5], + [107.0, 37.0, 228.0, 1.0], + ["==", ["get", "min_label"], 7.9], + [98.0, 74.0, 237.0, 1.0], + ["==", ["get", "min_label"], 8.0], + [83.0, 110.0, 234.0, 1.0], + ["==", ["get", "min_label"], 8.4], + [68.0, 146.0, 231.0, 1.0], + ["==", ["get", "min_label"], 8.5], + [34.0, 175.0, 214.0, 1.0], + ["==", ["get", "min_label"], 8.6], + [0.0, 204.0, 197.0, 1.0], + ["==", ["get", "min_label"], 8.9], + [0.0, 247.0, 146.0, 1.0], + ["==", ["get", "min_label"], 9.0], + [0.0, 251.0, 117.0, 1.0], + ["==", ["get", "min_label"], 9.5], + [0.0, 255.0, 88.0, 1.0], + ["==", ["get", "min_label"], 9.6], + [20.0, 255.0, 48.0, 1.0], + ["==", ["get", "min_label"], 10.0], + [40.0, 255.0, 8.0, 1.0], + ["==", ["get", "min_label"], 10.1], + [94.0, 255.0, 4.0, 1.0], + ["==", ["get", "min_label"], 10.2], + [147.0, 255.0, 0.0, 1.0], + [0.0, 0.0, 0.0, 0.0], + ], + "stroke-line-cap": "square", + "stroke-line-join": "bevel", + "stroke-width": 1.0, + }, + "opacity": 1.0, + "source": source_ids[6], + "symbologyState": { + "colorRamp": "cool", + "mode": "", + "nClasses": "", + "renderType": "Categorized", + "value": "min_label", + }, + "type": "line", + }, + "type": "VectorLayer", + "visible": True, + }, }, "layerTree": [ layer_ids[0], @@ -211,6 +325,8 @@ def test_qgis_saver(): "name": "group0", }, layer_ids[4], + layer_ids[5], + layer_ids[6], ], "sources": { source_ids[0]: { @@ -256,6 +372,20 @@ def test_qgis_saver(): }, "type": "GeoJSONSource", }, + source_ids[5]: { + "name": "Custom GeoJSON Layer Source", + "parameters": { + "path": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson" + }, + "type": "GeoJSONSource", + }, + source_ids[6]: { + "name": "Custom GeoJSON Layer Source", + "parameters": { + "path": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_roads.geojson" + }, + "type": "GeoJSONSource", + }, }, "metadata": {}, }