From 95bf91593c7276c6cbba72a0da3e16fbc2d04365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Mon, 17 Aug 2026 22:22:53 +0200 Subject: [PATCH 01/45] feat: added markers styling --- e2e-tests/e2e_test_data_initial.json | 14 ++ e2e-tests/tests/basic/test_marker_styles.py | 76 ++++++++++ .../components/MarkerPopup/MarkerPopup.jsx | 14 +- .../MarkerPopup/getTypedMarkerIcon.jsx | 109 ++++++++++++++ .../MarkerPopup/getTypedMarkerIcon.test.jsx | 137 ++++++++++++++++++ goodmap/data_models/location.py | 22 ++- goodmap/db.py | 65 +++++++++ goodmap/goodmap.py | 6 + goodmap/templates/map.html | 2 + tests/unit_tests/data_models/test_location.py | 26 ++++ tests/unit_tests/test_core_api.py | 33 +++++ tests/unit_tests/test_db.py | 114 +++++++++++++++ tests/unit_tests/test_goodmap.py | 57 ++++++++ 13 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 e2e-tests/tests/basic/test_marker_styles.py create mode 100644 frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx create mode 100644 frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index 17e8b58d..d894c6bb 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -272,6 +272,20 @@ "cars" ] }, + "marker_styles": { + "icon_field": "type_of_place", + "color_field": "speed_limit", + "icons": { + "big bridge": "M1 11h14v2H1zM2 7h1v4H2zM13 7h1v4h-1zM4 5h1v6H4zM11 5h1v6h-1zM7 4h2v7H7z", + "small bridge": "M2 9c2-3 10-3 12 0" + }, + "colors": { + "10": "#2e7d32", + "30": "#ef6c00", + "50": "#c62828" + }, + "default_color": "#2a81cb" + }, "visible_data": [ "remark", "accessible_by", diff --git a/e2e-tests/tests/basic/test_marker_styles.py b/e2e-tests/tests/basic/test_marker_styles.py new file mode 100644 index 00000000..d5f35f92 --- /dev/null +++ b/e2e-tests/tests/basic/test_marker_styles.py @@ -0,0 +1,76 @@ +""" +Marker Styles Tests + +Tests that the map picks pin icon/color per marker_styles (icon_field: +type_of_place, color_field: speed_limit - see e2e_test_data_initial.json), and +that a location with both a remark and a marker_styles match keeps its +type/color styling with an asterisk badge overlay, rather than losing it to +the plain asterisk icon (see getTypedMarkerIcon.jsx/MarkerPopup.jsx). +""" + +from playwright.sync_api import Page, expect + +from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, open_test_popup + +BIG_BRIDGE_GLYPH = "M1 11h14v2H1zM2 7h1v4H2zM13 7h1v4h-1zM4 5h1v6H4zM11 5h1v6h-1zM7 4h2v7H7z" +SMALL_BRIDGE_GLYPH = "M2 9c2-3 10-3 12 0" + + +class TestMarkerStyles: + """Test suite for marker_styles-driven pin icons/colors""" + + def test_fast_bridge_marker_uses_type_glyph_and_red_speed_color(self, page: Page): + """Pokoju (big bridge, speed_limit=50, no remark) is the only seeded bridge + with all three of lighting+benches+toilets (amenities is an "and" category - + see test_and_filter_within_category_narrows_results in test_map.py), so + checking all three isolates its marker without relying on clustering + distance/zoom assumptions.""" + page.goto(BASE_URL, wait_until="domcontentloaded") + + # "cars" is checked by default (Pokoju is cars-accessible); narrow further. + for amenity in ("lighting", "benches", "toilets"): + page.get_by_role("checkbox", name=amenity, exact=False).click() + + marker = page.locator(".custom-typed-marker-icon") + expect(marker).to_have_count(1, timeout=MARKER_LOAD_TIMEOUT) + + paths = marker.locator("path") + # First path is the pin shape itself, filled with speed_limit=50's color. + expect(paths.first).to_have_attribute("fill", "#c62828") + # Second path is the type_of_place glyph, configured for "big bridge". + expect(paths.nth(1)).to_have_attribute("d", BIG_BRIDGE_GLYPH) + # No remark on Pokoju, so no asterisk badge. + expect(marker.locator("text")).to_have_count(0) + + # Note: a second real-browser color case (e.g. speed_limit=10 -> green) isn't + # covered here. The only speed=10 bridge without a remark (Piaskowy) can't be + # isolated to a standalone marker via the left panel's filters - its amenities + # ([benches]) are a subset of a remarked neighbor's (Tumski, [lighting, + # benches]) barely 230m away, so any filter combo that includes Piaskowy also + # includes Tumski, and Leaflet.markercluster groups them into one cluster + # bubble at the map's default zoom, hiding both individual markers. The + # color-lookup logic itself (arbitrary field values, including a "10" -> + # green case) is covered generically at the unit level in + # frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx. + + def test_remarked_bridge_keeps_type_and_color_styling_with_asterisk_badge(self, page: Page): + """Zwierzyniecka has both a remark and marker_styles-matching fields + (small bridge, speed_limit=10) - it should render its normal typed/colored + pin plus an asterisk badge, not fall back to the plain asterisk icon + (every type_of_place/speed_limit value happens to be covered by + marker_styles in this seeded dataset, so that plain-icon fallback path + isn't exercised here - it's covered at the unit level instead, see + getTypedMarkerIcon.test.jsx's "falls back to the plain asterisk icon" + case).""" + page.goto(BASE_URL, wait_until="domcontentloaded") + open_test_popup(page) + + expect(page.locator('img[alt="Marker-Asterisk"]')).to_have_count(0) + + marker = page.locator(".custom-typed-marker-icon") + expect(marker).to_have_count(1, timeout=MARKER_LOAD_TIMEOUT) + + paths = marker.locator("path") + expect(paths.first).to_have_attribute("fill", "#2e7d32") # speed_limit=10 + expect(paths.nth(1)).to_have_attribute("d", SMALL_BRIDGE_GLYPH) + expect(marker.locator("text")).to_have_text("*") diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index 6bec59fb..a48dead3 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -10,6 +10,7 @@ import useMapStore from '../Map/store/map.store'; import LocationDetailsBox from './LocationDetails'; import MobilePopup from './MobilePopup'; import DesktopPopup from './DesktopPopup'; +import { getTypedMarkerIcon } from './getTypedMarkerIcon'; import iconAsterisk from '../../res/img/marker-icon-asterisk.png'; /** @@ -122,9 +123,16 @@ const MarkerPopup = ({ place }) => { alt: place.has_remark ? 'Marker-Asterisk' : 'Marker', }; - // Only add icon prop if we have a custom icon (for remarks) - // This prevents passing undefined which can cause issues with MarkerClusterGroup - if (place.has_remark) { + // Prefer a marker_styles match (getTypedMarkerIcon adds an asterisk badge to + // it when place.has_remark is set, so a remarked location keeps its type/color + // styling) and only fall back to the plain asterisk icon when there's no + // match to style - e.g. a legacy/unconfigured deployment. Only add an icon + // prop when we actually have a custom icon: passing icon={undefined} causes + // errors in MarkerClusterGroup during cluster zoom animations. + const typedIcon = getTypedMarkerIcon(place); + if (typedIcon) { + markerProps.icon = typedIcon; + } else if (place.has_remark) { markerProps.icon = asteriskIcon; } diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx new file mode 100644 index 00000000..92fb0482 --- /dev/null +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -0,0 +1,109 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DivIcon } from 'leaflet'; +import ReactDOMServer from 'react-dom/server'; + +const PIN_WIDTH = 30; +const PIN_HEIGHT = 42; +const FALLBACK_COLOR = '#2a81cb'; // leaflet default marker blue + +/** + * Teardrop pin shape (matches Leaflet's default marker silhouette) filled with + * `color`, optionally holding a 16x16 glyph (`glyphPath`) centered near its top, + * and an asterisk badge in the pin's own color scheme when `hasRemark` is set - + * so a remarked location keeps its type/color styling instead of being replaced + * by a plain, uncolored asterisk marker. + */ +const PinSvg = ({ color, glyphPath, hasRemark }) => ( + + + {glyphPath !== '' && } + {hasRemark && ( + + * + + )} + +); + +PinSvg.propTypes = { + color: PropTypes.string.isRequired, + glyphPath: PropTypes.string.isRequired, + hasRemark: PropTypes.bool.isRequired, +}; + +/** + * Builds a Leaflet icon for `place` based on the deployment's marker styling + * lookup table (window.MARKER_STYLES, set server-side from the map's + * `marker_styles` config - see goodmap's db.get_marker_styles), or returns + * `null` when neither `icon_field` nor `color_field` produced a configured + * lookup match - callers should omit the `icon` prop in that case and fall + * back to Leaflet's default marker (or the plain asterisk icon for a remarked + * location with no marker_styles match) so unconfigured/legacy deployments + * are unchanged. When `place.has_remark` is set and a match *was* found, the + * returned icon carries an asterisk badge instead of losing its type/color + * styling to the plain asterisk marker. + * + * Expected shape of window.MARKER_STYLES: + * { + * icon_field: 'type_of_place', // which location field selects the glyph + * color_field: 'status', // which location field selects the fill color + * icons: { parcel_locker: 'M2 4h12v9H2z...' }, // field value -> SVG path (16x16 box) + * colors: { open: '#2e7d32' }, // field value -> fill color + * default_color: '#2a81cb', // fallback fill color + * } + * + * @param {Object} place - Location data, as returned by GET /api/locations + * @returns {import('leaflet').DivIcon|null} + */ +export const getTypedMarkerIcon = place => { + const markerStyles = globalThis.MARKER_STYLES || {}; + const { + icon_field: iconField, + color_field: colorField, + icons, + colors, + default_color: defaultColor, + } = markerStyles; + + const glyphPath = (iconField && icons && icons[place[iconField]]) || ''; + const matchedColor = (colorField && colors && colors[place[colorField]]) || ''; + + if (!glyphPath && !matchedColor) { + return null; + } + + return new DivIcon({ + html: ReactDOMServer.renderToString( + , + ), + className: 'custom-typed-marker-icon', + iconSize: [PIN_WIDTH, PIN_HEIGHT], + iconAnchor: [PIN_WIDTH / 2, PIN_HEIGHT], + popupAnchor: [0, -PIN_HEIGHT], + }); +}; diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx new file mode 100644 index 00000000..e04dc73e --- /dev/null +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -0,0 +1,137 @@ +import { getTypedMarkerIcon } from '../../src/components/MarkerPopup/getTypedMarkerIcon'; + +// window.MARKER_STYLES is server-rendered JSON (see goodmap's map.html/db.get_marker_styles), +// so fixtures are parsed from JSON strings here too - keeps the snake_case backend field +// names (icon_field, color_field, default_color) faithful to what actually arrives. +const setMarkerStyles = json => { + globalThis.MARKER_STYLES = JSON.parse(json); +}; + +describe('getTypedMarkerIcon', () => { + afterEach(() => { + delete globalThis.MARKER_STYLES; + }); + + it('returns null when window.MARKER_STYLES is not set (legacy/unconfigured backend)', () => { + expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50] })).toBeNull(); + }); + + it('returns null when window.MARKER_STYLES is set but empty (default db config)', () => { + setMarkerStyles('{}'); + expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50] })).toBeNull(); + }); + + it('returns null when the place value has no matching icon or color entry', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "color_field": "pointStatus", + "icons": { "parcelLocker": "M0 0h16v16H0z" }, + "colors": { "open": "#2e7d32" } + }`); + + expect( + getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'unknownType' }), + ).toBeNull(); + }); + + it('builds a DivIcon when the icon field matches a configured glyph', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "parcelLocker": "M0 0h16v16H0z" } + }`); + + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + pointType: 'parcelLocker', + }); + + expect(icon).not.toBeNull(); + expect(icon.options.html).toContain('M0 0h16v16H0z'); + expect(icon.options.html).toContain('#2a81cb'); // fallback color, no color_field set + expect(icon.options.iconSize).toEqual([30, 42]); + }); + + it('builds a DivIcon when the color field matches a configured color, with no glyph', () => { + setMarkerStyles(`{ + "color_field": "pointStatus", + "colors": { "open": "#2e7d32" } + }`); + + const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointStatus: 'open' }); + + expect(icon).not.toBeNull(); + expect(icon.options.html).toContain('#2e7d32'); + }); + + it('picks the color matching each value on a multi-tier color_field (e.g. speed-based coloring)', () => { + setMarkerStyles(`{ + "color_field": "speedLimit", + "colors": { "10": "#2e7d32", "30": "#ef6c00", "50": "#c62828" } + }`); + + const iconFor = speedLimit => + getTypedMarkerIcon({ uuid: '1', position: [50, 50], speedLimit }); + + expect(iconFor('10').options.html).toContain('#2e7d32'); + expect(iconFor('30').options.html).toContain('#ef6c00'); + expect(iconFor('50').options.html).toContain('#c62828'); + }); + + it('adds an asterisk badge when place.has_remark is set and a match was found', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "parcelLocker": "M0 0h16v16H0z" } + }`); + + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + pointType: 'parcelLocker', + has_remark: true, + }); + + expect(icon).not.toBeNull(); + expect(icon.options.html).toContain('M0 0h16v16H0z'); // keeps the type glyph + expect(icon.options.html).toContain('>*'); // asterisk badge overlay + }); + + it('omits the asterisk badge when place.has_remark is not set', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "parcelLocker": "M0 0h16v16H0z" } + }`); + + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + pointType: 'parcelLocker', + }); + + expect(icon.options.html).not.toContain(' { + setMarkerStyles('{}'); + + expect( + getTypedMarkerIcon({ uuid: '1', position: [50, 50], has_remark: true }), + ).toBeNull(); + }); + + it('uses default_color from MARKER_STYLES when the matched value has no color entry', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "parcelLocker": "M0 0h16v16H0z" }, + "default_color": "#123456" + }`); + + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + pointType: 'parcelLocker', + }); + + expect(icon.options.html).toContain('#123456'); + }); +}); diff --git a/goodmap/data_models/location.py b/goodmap/data_models/location.py index 183ef652..ea8c4024 100644 --- a/goodmap/data_models/location.py +++ b/goodmap/data_models/location.py @@ -6,7 +6,7 @@ """ import warnings -from typing import Annotated, Any, Type, cast +from typing import Annotated, Any, ClassVar, Type, cast from annotated_types import Ge, Le from pydantic import ( @@ -37,6 +37,11 @@ class LocationBase(BaseModel, extra="allow"): uuid: str = Field(..., max_length=100) # TODO make this UUID and deprecate string remark: str | None = None + # Names of category fields whose values should ride along on basic_info(), + # e.g. so the frontend can pick a pin icon/color without a full detail fetch. + # Populated by create_location_model(); empty for the base class. + pin_marker_fields: ClassVar[frozenset[str]] = frozenset() + @model_validator(mode="before") @classmethod def validate_uuid_exists(cls, data: Any) -> Any: @@ -85,9 +90,18 @@ def model_dump(self, **kwargs) -> dict[str, Any]: return super().model_dump(**kwargs) def basic_info(self) -> dict[str, Any]: - """Get basic location information summary.""" + """Get basic location information summary. + + Includes the uuid/position/remark flag always shown on the map, plus the + value of any category field named in ``pin_marker_fields`` - enough for the + frontend to choose a pin icon/color without fetching full location detail. + """ data = self.model_dump(include={"uuid", "position"}) data["has_remark"] = bool(self.remark) + for field in sorted(self.pin_marker_fields): + value = getattr(self, field, None) + if value is not None: + data[field] = value return data @@ -255,9 +269,11 @@ def create_location_model( allowed = frozenset() fields[field_name] = _build_field_definition(field_type_str, allowed) - return create_model( + location_model = create_model( "Location", __base__=LocationBase, __module__="goodmap.data_models.location", **fields, ) + location_model.pin_marker_fields = frozenset(categories.keys()) & fields.keys() + return location_model diff --git a/goodmap/db.py b/goodmap/db.py index 7683861d..cdd3ec8b 100644 --- a/goodmap/db.py +++ b/goodmap/db.py @@ -562,6 +562,70 @@ def get_meta_data(db): return globals()[f"{db.module_name}_get_meta_data"] +# ------------------------------------------------ +# get_marker_styles + + +def google_json_db_get_marker_styles(self) -> dict[str, Any]: + """ + Retrieve marker icon/color styling configuration from Google Cloud Storage JSON blob. + + Returns: + dict: Pin styling lookup table (icon_field, color_field, icons, colors). + Returns empty dict if not found. + """ + return self.data.get("map", {}).get("marker_styles", {}) + + +def json_file_db_get_marker_styles(self) -> dict[str, Any]: + """ + Retrieve marker icon/color styling configuration from JSON file database. + + Returns: + dict: Pin styling lookup table (icon_field, color_field, icons, colors). + Returns empty dict if not found. + """ + return self.data.get("map", {}).get("marker_styles", {}) + + +def json_db_get_marker_styles(self) -> dict[str, Any]: + """ + Retrieve marker icon/color styling configuration from in-memory JSON database. + + Returns: + dict: Pin styling lookup table (icon_field, color_field, icons, colors). + Returns empty dict if not found. + """ + return self.data.get("marker_styles", {}) + + +def mongodb_db_get_marker_styles(self) -> dict[str, Any]: + """ + Retrieve marker icon/color styling configuration from MongoDB. + + Returns: + dict: Pin styling lookup table (icon_field, color_field, icons, colors). + Returns empty dict if config document not found or field missing. + """ + config_doc = self.db.config.find_one({"_id": "map_config"}) + if config_doc: + return config_doc.get("marker_styles", {}) + return {} + + +def get_marker_styles(db): + """ + Get the appropriate get_marker_styles function for the given database backend. + + Args: + db: Database instance (must have module_name attribute). + + Returns: + callable: Backend-specific get_marker_styles function. + """ + return globals()[f"{db.module_name}_get_marker_styles"] + + # ------------------------------------------------ # get_categories @@ -1777,6 +1841,7 @@ def extend_db_with_goodmap_queries(db, location_model): db.extend("get_data", get_data(db)) db.extend("get_visible_data", get_visible_data(db)) db.extend("get_meta_data", get_meta_data(db)) + db.extend("get_marker_styles", get_marker_styles(db)) db.extend("get_locations", get_locations(db, location_model)) db.extend("get_locations_paginated", get_locations_paginated(db, location_model)) db.extend("get_location", get_location(db, location_model)) diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 64139404..56ee2173 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -285,11 +285,17 @@ def index(): Returns: Rendered map.html template with feature flags and the plugin manifest """ + try: + marker_styles = app.db.get_marker_styles() # type: ignore[attr-defined] + except (KeyError, AttributeError): + marker_styles = {} + return render_template( "map.html", feature_flags=config.feature_flags, goodmap_frontend_lib_url=config.goodmap_frontend_lib_url, plugin_manifest=plugin_manifest, + marker_styles=marker_styles, ) @goodmap.route("/goodmap-admin") diff --git a/goodmap/templates/map.html b/goodmap/templates/map.html index d1b1eacb..7ada444f 100644 --- a/goodmap/templates/map.html +++ b/goodmap/templates/map.html @@ -121,6 +121,8 @@ window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }}; window.FEATURE_FLAGS = {{ feature_flags | tojson }}; window.PLUGIN_MANIFEST = {{ plugin_manifest | tojson }}; +// Deployment-specific pin icon/color lookup table, see goodmap/db.py's get_marker_styles. +window.MARKER_STYLES = {{ marker_styles | tojson }}; {% endblock %} diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index 3be93ab8..98e10d20 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -129,6 +129,32 @@ def test_category_validation_rejects_invalid_list_item(): location_model(uuid="2", tags=["red", "yellow"], position=(50, 50)) +def test_basic_info_includes_category_field_values(): + """basic_info() should surface category field values (for pin icon/color + selection) alongside the existing uuid/position/remark.""" + location_model = create_location_model( + obligatory_fields=[("type_of_place", "str"), ("name", "str")], + categories={"type_of_place": ["parcel_locker", "container"]}, + ) + location = location_model( + uuid="1", name="test", type_of_place="parcel_locker", position=(50, 50) + ) + assert location.basic_info() == { + "uuid": "1", + "position": (50, 50), + "remark": False, + "type_of_place": "parcel_locker", + } + + +def test_basic_info_omits_category_fields_when_none_configured(): + """Backward compatibility: deployments without categories get the original + uuid/position/remark shape, unchanged.""" + location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) + location = location_model(uuid="1", name="test", position=(50, 50)) + assert location.basic_info() == {"uuid": "1", "position": (50, 50), "remark": False} + + def test_create_location_model_with_int_field(): """Test that non-str simple fields (like int) are created without max_length.""" location_model = create_location_model(obligatory_fields=[("capacity", "int")], categories={}) diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index 6dbb03e5..2a7ec185 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -315,6 +315,39 @@ def test_get_locations_accepts_valid_and_undeclared_parameters(test_app, query): assert response.status_code == 200 +def test_get_locations_includes_category_field_for_pin_styling(): + """/api/locations should surface category field values (e.g. a point-type + category), so the frontend can pick a marker icon/color without a full + per-location detail fetch.""" + client = create_test_app( + db_overrides={ + "categories": {"point_type": ["parcel_locker", "container"]}, + "location_obligatory_fields": [("point_type", "str"), ("name", "str")], + "data": [ + { + "name": "locker-1", + "position": [50, 50], + "point_type": "parcel_locker", + "uuid": "11111111-1111-1111-1111-111111111111", + }, + ], + "visible_data": ["name", "point_type"], + } + ) + + response = client.get("/api/locations") + + assert response.status_code == 200 + assert response.json == [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "position": [50, 50], + "has_remark": False, + "point_type": "parcel_locker", + }, + ] + + def test_get_locations_multi_value_same_category_uses_or_semantics(): """Selecting several checkboxes within one category should return the union of matches, not only entries that have every selected value.""" diff --git a/tests/unit_tests/test_db.py b/tests/unit_tests/test_db.py index d915ce2a..29616092 100644 --- a/tests/unit_tests/test_db.py +++ b/tests/unit_tests/test_db.py @@ -26,6 +26,7 @@ google_json_db_get_data, google_json_db_get_location_obligatory_fields, google_json_db_get_locations_paginated, + google_json_db_get_marker_styles, google_json_db_get_meta_data, google_json_db_get_visible_data, json_db_add_location, @@ -57,6 +58,7 @@ json_file_db_get_data, json_file_db_get_location_obligatory_fields, json_file_db_get_locations_paginated, + json_file_db_get_marker_styles, json_file_db_get_meta_data, json_file_db_get_report, json_file_db_get_reports, @@ -81,6 +83,7 @@ mongodb_db_get_location_obligatory_fields, mongodb_db_get_locations, mongodb_db_get_locations_paginated, + mongodb_db_get_marker_styles, mongodb_db_get_meta_data, mongodb_db_get_report, mongodb_db_get_reports, @@ -296,6 +299,41 @@ def test_json_file_db_get_meta_data_empty(): assert result == {} +@mock.patch( + "builtins.open", + mock.mock_open( + read_data=json.dumps( + { + "map": { + "marker_styles": { + "icon_field": "type_of_place", + "color_field": "status", + "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "colors": {"open": "#2e7d32"}, + } + } + } + ) + ), +) +def test_json_file_db_get_marker_styles(): + db = JsonFile("/fake/path/data.json") + result = json_file_db_get_marker_styles(db) + assert result == { + "icon_field": "type_of_place", + "color_field": "status", + "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "colors": {"open": "#2e7d32"}, + } + + +@mock.patch("builtins.open", mock.mock_open(read_data=json.dumps({"map": {}}))) +def test_json_file_db_get_marker_styles_empty(): + db = JsonFile("/fake/path/data.json") + result = json_file_db_get_marker_styles(db) + assert result == {} + + # Test get_visible_data and get_meta_data for google_json_db @mock.patch("platzky.db.google_json_db.Client") def test_google_json_db_get_visible_data(mock_cli): @@ -337,6 +375,38 @@ def test_google_json_db_get_meta_data_empty(mock_cli): assert result == {} +@mock.patch("platzky.db.google_json_db.Client") +def test_google_json_db_get_marker_styles(mock_cli): + mock_cli.return_value.bucket.return_value.blob.return_value.download_as_text.return_value = ( + json.dumps( + { + "map": { + "marker_styles": { + "icon_field": "type_of_place", + "icons": {"parcel_locker": "M0 0h16v16H0z"}, + } + } + } + ) + ) + db = GoogleJsonDb("bucket", "blob") + result = google_json_db_get_marker_styles(db) + assert result == { + "icon_field": "type_of_place", + "icons": {"parcel_locker": "M0 0h16v16H0z"}, + } + + +@mock.patch("platzky.db.google_json_db.Client") +def test_google_json_db_get_marker_styles_empty(mock_cli): + mock_cli.return_value.bucket.return_value.blob.return_value.download_as_text.return_value = ( + json.dumps({"map": {}}) + ) + db = GoogleJsonDb("bucket", "blob") + result = google_json_db_get_marker_styles(db) + assert result == {} + + def test_get_location_from_raw_data_found(): raw = {"data": [{"uuid": "X", "position": [0, 0]}]} Location = create_location_model([], {}) @@ -1092,6 +1162,50 @@ def test_mongodb_db_get_meta_data_no_config(mock_client): assert result == {} +@mock.patch("platzky.db.mongodb_db.MongoClient") +def test_mongodb_db_get_marker_styles(mock_client): + mock_db = mock.Mock() + mock_client.return_value.__getitem__.return_value = mock_db + mock_db.config.find_one.return_value = { + "_id": "map_config", + "marker_styles": { + "icon_field": "type_of_place", + "icons": {"parcel_locker": "M0 0h16v16H0z"}, + }, + } + + db = MongoDB("mongodb://localhost:27017", "test_db") + result = mongodb_db_get_marker_styles(db) + assert result == { + "icon_field": "type_of_place", + "icons": {"parcel_locker": "M0 0h16v16H0z"}, + } + + +@mock.patch("platzky.db.mongodb_db.MongoClient") +def test_mongodb_db_get_marker_styles_empty(mock_client): + mock_db = mock.Mock() + mock_client.return_value.__getitem__.return_value = mock_db + mock_db.config.find_one.return_value = { + "_id": "map_config", + } + + db = MongoDB("mongodb://localhost:27017", "test_db") + result = mongodb_db_get_marker_styles(db) + assert result == {} + + +@mock.patch("platzky.db.mongodb_db.MongoClient") +def test_mongodb_db_get_marker_styles_no_config(mock_client): + mock_db = mock.Mock() + mock_client.return_value.__getitem__.return_value = mock_db + mock_db.config.find_one.return_value = None + + db = MongoDB("mongodb://localhost:27017", "test_db") + result = mongodb_db_get_marker_styles(db) + assert result == {} + + @mock.patch("platzky.db.mongodb_db.MongoClient") def test_mongodb_db_get_location(mock_client): mock_db = mock.Mock() diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 5fab3be2..f8a4766d 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -107,6 +107,63 @@ def test_frontend_lib_url_uses_bundled_static_when_present(): assert 'src="/static/frontend/index.min.js"' in response.data.decode("utf-8") +def test_map_route_includes_marker_styles(): + """The frontend picks pin icon/color per marker_styles.config's iconField/colorField + at runtime from window.MARKER_STYLES - a deployment-specific lookup table that lives + in the database (like categories/visible_data), not hardcoded in the frontend build.""" + config = GoodmapConfig( + APP_NAME="test_app", + SECRET_KEY="test_secret", + USE_WWW=False, + BLOG_PREFIX="/blog", + DB=JsonDbConfig( + DATA={ + "site_content": {"pages": []}, + "categories": {"type_of_place": ["parcel_locker", "container"]}, + "marker_styles": { + "icon_field": "type_of_place", + "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "colors": {}, + }, + }, + TYPE="json", + ), + ) + app = goodmap.create_app_from_config(config) + app.config["WTF_CSRF_ENABLED"] = False # NOSONAR + client = app.test_client() + + response = client.get("/map") + assert response.status_code == 200 + + response_text = response.data.decode("utf-8") + assert "MARKER_STYLES" in response_text + assert "icon_field" in response_text + assert "parcel_locker" in response_text + + +def test_map_route_marker_styles_defaults_to_empty(): + """Deployments that don't configure marker_styles get an empty object, so the + frontend falls back to Leaflet's default marker - no behavior change.""" + config = GoodmapConfig( + APP_NAME="test_app", + SECRET_KEY="test_secret", + USE_WWW=False, + BLOG_PREFIX="/blog", + DB=JsonDbConfig( + DATA={"site_content": {"pages": []}, "categories": {}}, + TYPE="json", + ), + ) + app = goodmap.create_app_from_config(config) + app.config["WTF_CSRF_ENABLED"] = False # NOSONAR + client = app.test_client() + + response = client.get("/map") + assert response.status_code == 200 + assert "window.MARKER_STYLES={};" in response.data.decode("utf-8") + + def test_map_route_includes_photo_constraints(): """The frontend sources photo upload limits (max size, allowed types) live from the backend's AttachmentConfig rather than hardcoding its own copy - this test From c50fa7dca70a0f61f6186e01626fdd675bb30e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 00:56:24 +0200 Subject: [PATCH 02/45] fixes --- frontend/src/components/MarkerPopup/ReportProblemForm.jsx | 2 +- tests/unit_tests/data_models/test_location.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/MarkerPopup/ReportProblemForm.jsx b/frontend/src/components/MarkerPopup/ReportProblemForm.jsx index 6c46004a..ec66f7b6 100644 --- a/frontend/src/components/MarkerPopup/ReportProblemForm.jsx +++ b/frontend/src/components/MarkerPopup/ReportProblemForm.jsx @@ -212,7 +212,7 @@ const ReportProblemForm = ({ placeId }) => { if (schemaError) { return ( - {t('loadReportFormError')} + {t('loadReportFormError')}
{t('retry')} diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index 98e10d20..0330733a 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -131,7 +131,7 @@ def test_category_validation_rejects_invalid_list_item(): def test_basic_info_includes_category_field_values(): """basic_info() should surface category field values (for pin icon/color - selection) alongside the existing uuid/position/remark.""" + selection) alongside the existing uuid/position/has_remark.""" location_model = create_location_model( obligatory_fields=[("type_of_place", "str"), ("name", "str")], categories={"type_of_place": ["parcel_locker", "container"]}, @@ -142,17 +142,17 @@ def test_basic_info_includes_category_field_values(): assert location.basic_info() == { "uuid": "1", "position": (50, 50), - "remark": False, + "has_remark": False, "type_of_place": "parcel_locker", } def test_basic_info_omits_category_fields_when_none_configured(): """Backward compatibility: deployments without categories get the original - uuid/position/remark shape, unchanged.""" + uuid/position/has_remark shape, unchanged.""" location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) location = location_model(uuid="1", name="test", position=(50, 50)) - assert location.basic_info() == {"uuid": "1", "position": (50, 50), "remark": False} + assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} def test_create_location_model_with_int_field(): From 242cb5f5943e2dba76d6d712fedac1d320bebe77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 01:22:17 +0200 Subject: [PATCH 03/45] fix popup --- e2e-tests/e2e_test_data_initial.json | 4 +- e2e-tests/tests/basic/test_marker_styles.py | 31 +++-- .../components/MarkerPopup/MarkerPopup.jsx | 2 +- .../MarkerPopup/getTypedMarkerIcon.jsx | 106 +++++++++++------- .../MarkerPopup/getTypedMarkerIcon.test.jsx | 45 ++++++-- 5 files changed, 125 insertions(+), 63 deletions(-) diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index d894c6bb..8af116ba 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -276,8 +276,8 @@ "icon_field": "type_of_place", "color_field": "speed_limit", "icons": { - "big bridge": "M1 11h14v2H1zM2 7h1v4H2zM13 7h1v4h-1zM4 5h1v6H4zM11 5h1v6h-1zM7 4h2v7H7z", - "small bridge": "M2 9c2-3 10-3 12 0" + "big bridge": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg", + "small bridge": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/footprints-fill.svg" }, "colors": { "10": "#2e7d32", diff --git a/e2e-tests/tests/basic/test_marker_styles.py b/e2e-tests/tests/basic/test_marker_styles.py index d5f35f92..4027bea8 100644 --- a/e2e-tests/tests/basic/test_marker_styles.py +++ b/e2e-tests/tests/basic/test_marker_styles.py @@ -12,8 +12,16 @@ from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, open_test_popup -BIG_BRIDGE_GLYPH = "M1 11h14v2H1zM2 7h1v4H2zM13 7h1v4h-1zM4 5h1v6H4zM11 5h1v6h-1zM7 4h2v7H7z" -SMALL_BRIDGE_GLYPH = "M2 9c2-3 10-3 12 0" +# "big bridge" and "small bridge" each get their own Phosphor Icons (MIT) glyph - +# see e2e_test_data_initial.json's marker_styles.icons and getTypedMarkerIcon.jsx +# (icon URLs are CSS mask-image'd onto the pin, tinted by the matched color, +# rather than embedded as inline SVG path data). +BIG_BRIDGE_GLYPH_URL = ( + "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" +) +SMALL_BRIDGE_GLYPH_URL = ( + "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/footprints-fill.svg" +) class TestMarkerStyles: @@ -34,11 +42,13 @@ def test_fast_bridge_marker_uses_type_glyph_and_red_speed_color(self, page: Page marker = page.locator(".custom-typed-marker-icon") expect(marker).to_have_count(1, timeout=MARKER_LOAD_TIMEOUT) - paths = marker.locator("path") - # First path is the pin shape itself, filled with speed_limit=50's color. - expect(paths.first).to_have_attribute("fill", "#c62828") - # Second path is the type_of_place glyph, configured for "big bridge". - expect(paths.nth(1)).to_have_attribute("d", BIG_BRIDGE_GLYPH) + # The pin shape itself, filled with speed_limit=50's color. + expect(marker.locator("path")).to_have_attribute("fill", "#c62828") + # The type_of_place glyph, configured for "big bridge" - masked onto a div + # via CSS rather than embedded as an inline . + glyph = marker.locator(".custom-typed-marker-glyph") + expect(glyph).to_have_count(1) + expect(glyph).to_have_css("mask-image", f'url("{BIG_BRIDGE_GLYPH_URL}")') # No remark on Pokoju, so no asterisk badge. expect(marker.locator("text")).to_have_count(0) @@ -70,7 +80,8 @@ def test_remarked_bridge_keeps_type_and_color_styling_with_asterisk_badge(self, marker = page.locator(".custom-typed-marker-icon") expect(marker).to_have_count(1, timeout=MARKER_LOAD_TIMEOUT) - paths = marker.locator("path") - expect(paths.first).to_have_attribute("fill", "#2e7d32") # speed_limit=10 - expect(paths.nth(1)).to_have_attribute("d", SMALL_BRIDGE_GLYPH) + expect(marker.locator("path")).to_have_attribute("fill", "#2e7d32") # speed_limit=10 + glyph = marker.locator(".custom-typed-marker-glyph") + expect(glyph).to_have_count(1) + expect(glyph).to_have_css("mask-image", f'url("{SMALL_BRIDGE_GLYPH_URL}")') expect(marker.locator("text")).to_have_text("*") diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index a48dead3..b18e5710 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -10,7 +10,7 @@ import useMapStore from '../Map/store/map.store'; import LocationDetailsBox from './LocationDetails'; import MobilePopup from './MobilePopup'; import DesktopPopup from './DesktopPopup'; -import { getTypedMarkerIcon } from './getTypedMarkerIcon'; +import getTypedMarkerIcon from './getTypedMarkerIcon'; import iconAsterisk from '../../res/img/marker-icon-asterisk.png'; /** diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 92fb0482..1d4a2588 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -7,48 +7,74 @@ const PIN_WIDTH = 30; const PIN_HEIGHT = 42; const FALLBACK_COLOR = '#2a81cb'; // leaflet default marker blue +const GLYPH_SIZE = 16; +const GLYPH_OFFSET_TOP = 6; +const GLYPH_OFFSET_LEFT = 7; + /** * Teardrop pin shape (matches Leaflet's default marker silhouette) filled with - * `color`, optionally holding a 16x16 glyph (`glyphPath`) centered near its top, - * and an asterisk badge in the pin's own color scheme when `hasRemark` is set - - * so a remarked location keeps its type/color styling instead of being replaced - * by a plain, uncolored asterisk marker. + * `color`, optionally holding a glyph (`glyphUrl`, an icon image masked to the + * pin's own color via CSS mask-image so it doesn't need to be fetched or + * recolored server-side) centered near its top, and an asterisk badge in the + * pin's own color scheme when `hasRemark` is set - so a remarked location + * keeps its type/color styling instead of being replaced by a plain, + * uncolored asterisk marker. */ -const PinSvg = ({ color, glyphPath, hasRemark }) => ( - - - {glyphPath !== '' && } - {hasRemark && ( - - * - +const PinSvg = ({ color, glyphUrl, hasRemark }) => ( +
+ + + {hasRemark && ( + + * + + )} + + {glyphUrl !== '' && ( +
)} - +
); PinSvg.propTypes = { color: PropTypes.string.isRequired, - glyphPath: PropTypes.string.isRequired, + glyphUrl: PropTypes.string.isRequired, hasRemark: PropTypes.bool.isRequired, }; @@ -68,7 +94,7 @@ PinSvg.propTypes = { * { * icon_field: 'type_of_place', // which location field selects the glyph * color_field: 'status', // which location field selects the fill color - * icons: { parcel_locker: 'M2 4h12v9H2z...' }, // field value -> SVG path (16x16 box) + * icons: { parcel_locker: 'https://cdn.example.com/parcel-locker.svg' }, // field value -> icon URL, masked+tinted via CSS (see PinSvg) * colors: { open: '#2e7d32' }, // field value -> fill color * default_color: '#2a81cb', // fallback fill color * } @@ -76,7 +102,7 @@ PinSvg.propTypes = { * @param {Object} place - Location data, as returned by GET /api/locations * @returns {import('leaflet').DivIcon|null} */ -export const getTypedMarkerIcon = place => { +const getTypedMarkerIcon = place => { const markerStyles = globalThis.MARKER_STYLES || {}; const { icon_field: iconField, @@ -86,10 +112,10 @@ export const getTypedMarkerIcon = place => { default_color: defaultColor, } = markerStyles; - const glyphPath = (iconField && icons && icons[place[iconField]]) || ''; + const glyphUrl = (iconField && icons && icons[place[iconField]]) || ''; const matchedColor = (colorField && colors && colors[place[colorField]]) || ''; - if (!glyphPath && !matchedColor) { + if (!glyphUrl && !matchedColor) { return null; } @@ -97,7 +123,7 @@ export const getTypedMarkerIcon = place => { html: ReactDOMServer.renderToString( , ), @@ -107,3 +133,5 @@ export const getTypedMarkerIcon = place => { popupAnchor: [0, -PIN_HEIGHT], }); }; + +export default getTypedMarkerIcon; diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index e04dc73e..9c73c81a 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -1,4 +1,4 @@ -import { getTypedMarkerIcon } from '../../src/components/MarkerPopup/getTypedMarkerIcon'; +import getTypedMarkerIcon from '../../src/components/MarkerPopup/getTypedMarkerIcon'; // window.MARKER_STYLES is server-rendered JSON (see goodmap's map.html/db.get_marker_styles), // so fixtures are parsed from JSON strings here too - keeps the snake_case backend field @@ -25,7 +25,7 @@ describe('getTypedMarkerIcon', () => { setMarkerStyles(`{ "icon_field": "pointType", "color_field": "pointStatus", - "icons": { "parcelLocker": "M0 0h16v16H0z" }, + "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, "colors": { "open": "#2e7d32" } }`); @@ -37,7 +37,7 @@ describe('getTypedMarkerIcon', () => { it('builds a DivIcon when the icon field matches a configured glyph', () => { setMarkerStyles(`{ "icon_field": "pointType", - "icons": { "parcelLocker": "M0 0h16v16H0z" } + "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } }`); const icon = getTypedMarkerIcon({ @@ -47,11 +47,36 @@ describe('getTypedMarkerIcon', () => { }); expect(icon).not.toBeNull(); - expect(icon.options.html).toContain('M0 0h16v16H0z'); + expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); expect(icon.options.html).toContain('#2a81cb'); // fallback color, no color_field set expect(icon.options.iconSize).toEqual([30, 42]); }); + it('masks the icon URL through CSS so it picks up the matched color, instead of embedding SVG path data', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "color_field": "pointStatus", + "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, + "colors": { "open": "#2e7d32" } + }`); + + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + pointType: 'parcelLocker', + pointStatus: 'open', + }); + + // the glyph URL drives a CSS mask (mask-image / -webkit-mask-image) on a + //
, not an inline , so any icon set (not just + // single-path ones) works and no extra is added beyond the pin + // body's own teardrop shape. + expect(icon.options.html).toContain( + 'mask-image:url(https://cdn.example.com/parcel-locker.svg)', + ); + expect(icon.options.html.match(/ { setMarkerStyles(`{ "color_field": "pointStatus", @@ -81,7 +106,7 @@ describe('getTypedMarkerIcon', () => { it('adds an asterisk badge when place.has_remark is set and a match was found', () => { setMarkerStyles(`{ "icon_field": "pointType", - "icons": { "parcelLocker": "M0 0h16v16H0z" } + "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } }`); const icon = getTypedMarkerIcon({ @@ -92,14 +117,14 @@ describe('getTypedMarkerIcon', () => { }); expect(icon).not.toBeNull(); - expect(icon.options.html).toContain('M0 0h16v16H0z'); // keeps the type glyph + expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); // keeps the type glyph expect(icon.options.html).toContain('>*'); // asterisk badge overlay }); it('omits the asterisk badge when place.has_remark is not set', () => { setMarkerStyles(`{ "icon_field": "pointType", - "icons": { "parcelLocker": "M0 0h16v16H0z" } + "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } }`); const icon = getTypedMarkerIcon({ @@ -114,15 +139,13 @@ describe('getTypedMarkerIcon', () => { it('returns null (falls back to the plain asterisk icon) when has_remark is set but nothing matches', () => { setMarkerStyles('{}'); - expect( - getTypedMarkerIcon({ uuid: '1', position: [50, 50], has_remark: true }), - ).toBeNull(); + expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50], has_remark: true })).toBeNull(); }); it('uses default_color from MARKER_STYLES when the matched value has no color entry', () => { setMarkerStyles(`{ "icon_field": "pointType", - "icons": { "parcelLocker": "M0 0h16v16H0z" }, + "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, "default_color": "#123456" }`); From c76d8efee6b500cd25d8c190fe54cce6de5ceffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 02:52:42 +0200 Subject: [PATCH 04/45] some icon fixes --- e2e-tests/tests/basic/test_marker_styles.py | 13 +- .../MarkerPopup/getTypedMarkerIcon.jsx | 131 +++++++++++------- .../MarkerPopup/getTypedMarkerIcon.test.jsx | 16 +-- 3 files changed, 94 insertions(+), 66 deletions(-) diff --git a/e2e-tests/tests/basic/test_marker_styles.py b/e2e-tests/tests/basic/test_marker_styles.py index 4027bea8..1eb2d47e 100644 --- a/e2e-tests/tests/basic/test_marker_styles.py +++ b/e2e-tests/tests/basic/test_marker_styles.py @@ -42,15 +42,17 @@ def test_fast_bridge_marker_uses_type_glyph_and_red_speed_color(self, page: Page marker = page.locator(".custom-typed-marker-icon") expect(marker).to_have_count(1, timeout=MARKER_LOAD_TIMEOUT) - # The pin shape itself, filled with speed_limit=50's color. - expect(marker.locator("path")).to_have_attribute("fill", "#c62828") + # The pin shape itself (a masked div, not an inline ), filled with + # speed_limit=50's color. + pin = marker.locator(".custom-typed-marker-pin") + expect(pin).to_have_css("background-color", "rgb(198, 40, 40)") # #c62828 # The type_of_place glyph, configured for "big bridge" - masked onto a div # via CSS rather than embedded as an inline . glyph = marker.locator(".custom-typed-marker-glyph") expect(glyph).to_have_count(1) expect(glyph).to_have_css("mask-image", f'url("{BIG_BRIDGE_GLYPH_URL}")') # No remark on Pokoju, so no asterisk badge. - expect(marker.locator("text")).to_have_count(0) + expect(marker.locator("span")).to_have_count(0) # Note: a second real-browser color case (e.g. speed_limit=10 -> green) isn't # covered here. The only speed=10 bridge without a remark (Piaskowy) can't be @@ -80,8 +82,9 @@ def test_remarked_bridge_keeps_type_and_color_styling_with_asterisk_badge(self, marker = page.locator(".custom-typed-marker-icon") expect(marker).to_have_count(1, timeout=MARKER_LOAD_TIMEOUT) - expect(marker.locator("path")).to_have_attribute("fill", "#2e7d32") # speed_limit=10 + pin = marker.locator(".custom-typed-marker-pin") + expect(pin).to_have_css("background-color", "rgb(46, 125, 50)") # #2e7d32 (speed_limit=10) glyph = marker.locator(".custom-typed-marker-glyph") expect(glyph).to_have_count(1) expect(glyph).to_have_css("mask-image", f'url("{SMALL_BRIDGE_GLYPH_URL}")') - expect(marker.locator("text")).to_have_text("*") + expect(marker.locator("span")).to_have_text("*") diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 1d4a2588..d9a77a35 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -3,53 +3,66 @@ import PropTypes from 'prop-types'; import { DivIcon } from 'leaflet'; import ReactDOMServer from 'react-dom/server'; -const PIN_WIDTH = 30; -const PIN_HEIGHT = 42; +const PIN_WIDTH = 72; +const PIN_HEIGHT = 80; const FALLBACK_COLOR = '#2a81cb'; // leaflet default marker blue -const GLYPH_SIZE = 16; -const GLYPH_OFFSET_TOP = 6; -const GLYPH_OFFSET_LEFT = 7; +// Phosphor Icons (MIT, https://phosphoricons.com/) "map-pin-simple" glyph - +// a solid ball on a thin stem, not a balloon-style teardrop - reused as the +// pin body itself via CSS mask-image so we don't hand-draw/maintain our own +// pin shape - see PinIcon below. Its head is a solid circle (no cutout) +// centered at (50%, ~28%) of the box, so the glyph below sits inside that +// circle rather than fighting a hole like the balloon-pin design did. PIN_WIDTH +// is deliberately wider than the icon's native aspect ratio (mask-size 100% +// 100% stretches non-uniformly to fit) so the ball has real room for the +// glyph - the icon's own head is quite narrow relative to its height. +const PIN_SHAPE_URL = + 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/map-pin-simple-fill.svg'; + +// The icon's own artwork doesn't reach the bottom of its 256x256 viewBox - +// there's blank margin below the stem's rounded tip (part of Phosphor's +// standard icon padding). Since the mask is stretched to fill the box +// exactly, that margin becomes real empty space at the bottom of our div - so +// the anchor Leaflet pins to the map coordinate has to target the actual +// rendered tip position, not the box's bottom edge, or the marker floats +// above its true location. Measured empirically (screenshot pixel-row of the +// last visible fill pixel) rather than computed from the path's raw +// coordinates, since drop-shadow/antialiasing shift the rendered edge a +// little from the raw path's numbers. +const STEM_TIP_FRACTION = 0.8875; + +const GLYPH_SIZE = 24; +const GLYPH_OFFSET_TOP = 10; +const GLYPH_OFFSET_LEFT = 24; + +const maskStyle = (url, color) => ({ + backgroundColor: color, + WebkitMaskImage: `url(${url})`, + maskImage: `url(${url})`, + WebkitMaskSize: '100% 100%', + maskSize: '100% 100%', + WebkitMaskRepeat: 'no-repeat', + maskRepeat: 'no-repeat', +}); /** - * Teardrop pin shape (matches Leaflet's default marker silhouette) filled with - * `color`, optionally holding a glyph (`glyphUrl`, an icon image masked to the - * pin's own color via CSS mask-image so it doesn't need to be fetched or - * recolored server-side) centered near its top, and an asterisk badge in the - * pin's own color scheme when `hasRemark` is set - so a remarked location - * keeps its type/color styling instead of being replaced by a plain, - * uncolored asterisk marker. + * Pin shape (Phosphor's map-pin-simple glyph, masked to `color`), optionally + * holding a glyph (`glyphUrl`, masked to white) inside its head, and an + * asterisk badge in the pin's own color scheme when `hasRemark` is set - so a + * remarked location keeps its type/color styling instead of being replaced by + * a plain, uncolored asterisk marker. */ -const PinSvg = ({ color, glyphUrl, hasRemark }) => ( +const PinIcon = ({ color, glyphUrl, hasRemark }) => (
- - - {hasRemark && ( - - * - - )} - +
{glyphUrl !== '' && (
( left: GLYPH_OFFSET_LEFT, width: GLYPH_SIZE, height: GLYPH_SIZE, - backgroundColor: '#ffffff', - WebkitMaskImage: `url(${glyphUrl})`, - maskImage: `url(${glyphUrl})`, - WebkitMaskSize: 'contain', - maskSize: 'contain', - WebkitMaskRepeat: 'no-repeat', - maskRepeat: 'no-repeat', + ...maskStyle(glyphUrl, '#ffffff'), }} /> )} + {hasRemark && ( + [-1, 1].map(y => `${x}px ${y}px 0 ${color}`)) + .join(', '), + }} + > + * + + )}
); -PinSvg.propTypes = { +PinIcon.propTypes = { color: PropTypes.string.isRequired, glyphUrl: PropTypes.string.isRequired, hasRemark: PropTypes.bool.isRequired, @@ -94,7 +119,7 @@ PinSvg.propTypes = { * { * icon_field: 'type_of_place', // which location field selects the glyph * color_field: 'status', // which location field selects the fill color - * icons: { parcel_locker: 'https://cdn.example.com/parcel-locker.svg' }, // field value -> icon URL, masked+tinted via CSS (see PinSvg) + * icons: { parcel_locker: 'https://cdn.example.com/parcel-locker.svg' }, // field value -> icon URL, masked+tinted via CSS (see PinIcon) * colors: { open: '#2e7d32' }, // field value -> fill color * default_color: '#2a81cb', // fallback fill color * } @@ -121,7 +146,7 @@ const getTypedMarkerIcon = place => { return new DivIcon({ html: ReactDOMServer.renderToString( - { ), className: 'custom-typed-marker-icon', iconSize: [PIN_WIDTH, PIN_HEIGHT], - iconAnchor: [PIN_WIDTH / 2, PIN_HEIGHT], - popupAnchor: [0, -PIN_HEIGHT], + iconAnchor: [PIN_WIDTH / 2, PIN_HEIGHT * STEM_TIP_FRACTION], + popupAnchor: [0, -PIN_HEIGHT * STEM_TIP_FRACTION], }); }; diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index 9c73c81a..6074f9dc 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -49,7 +49,7 @@ describe('getTypedMarkerIcon', () => { expect(icon).not.toBeNull(); expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); expect(icon.options.html).toContain('#2a81cb'); // fallback color, no color_field set - expect(icon.options.iconSize).toEqual([30, 42]); + expect(icon.options.iconSize).toEqual([72, 80]); }); it('masks the icon URL through CSS so it picks up the matched color, instead of embedding SVG path data', () => { @@ -67,14 +67,14 @@ describe('getTypedMarkerIcon', () => { pointStatus: 'open', }); - // the glyph URL drives a CSS mask (mask-image / -webkit-mask-image) on a - //
, not an inline , so any icon set (not just - // single-path ones) works and no extra is added beyond the pin - // body's own teardrop shape. + // both the pin body (map-pin-fill.svg) and the glyph are CSS-masked + //
s tinted via background-color, not inline SVG , so + // any icon set (not just single-path ones) works for either. expect(icon.options.html).toContain( 'mask-image:url(https://cdn.example.com/parcel-locker.svg)', ); - expect(icon.options.html.match(/ { @@ -118,7 +118,7 @@ describe('getTypedMarkerIcon', () => { expect(icon).not.toBeNull(); expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); // keeps the type glyph - expect(icon.options.html).toContain('>*'); // asterisk badge overlay + expect(icon.options.html).toContain('>*'); // asterisk badge overlay }); it('omits the asterisk badge when place.has_remark is not set', () => { @@ -133,7 +133,7 @@ describe('getTypedMarkerIcon', () => { pointType: 'parcelLocker', }); - expect(icon.options.html).not.toContain('*'); }); it('returns null (falls back to the plain asterisk icon) when has_remark is set but nothing matches', () => { From b99e8e50fdcd000165715d75ec87f64539c32668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 02:56:56 +0200 Subject: [PATCH 05/45] fix lint --- frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index d9a77a35..e9e8e619 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -137,8 +137,8 @@ const getTypedMarkerIcon = place => { default_color: defaultColor, } = markerStyles; - const glyphUrl = (iconField && icons && icons[place[iconField]]) || ''; - const matchedColor = (colorField && colors && colors[place[colorField]]) || ''; + const glyphUrl = icons?.[place[iconField]] || ''; + const matchedColor = colors?.[place[colorField]] || ''; if (!glyphUrl && !matchedColor) { return null; From e15aa031565074a55f4c3a21f62405134cdadf7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 03:01:16 +0200 Subject: [PATCH 06/45] fix linting --- tests/unit_tests/data_models/test_location.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index 0330733a..a9cb1d5c 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -1,8 +1,9 @@ import warnings +from typing import cast import pytest -from goodmap.data_models.location import create_location_model +from goodmap.data_models.location import LocationBase, create_location_model from goodmap.exceptions import LocationValidationError @@ -139,6 +140,7 @@ def test_basic_info_includes_category_field_values(): location = location_model( uuid="1", name="test", type_of_place="parcel_locker", position=(50, 50) ) + location = cast(LocationBase, location) assert location.basic_info() == { "uuid": "1", "position": (50, 50), @@ -152,6 +154,7 @@ def test_basic_info_omits_category_fields_when_none_configured(): uuid/position/has_remark shape, unchanged.""" location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) location = location_model(uuid="1", name="test", position=(50, 50)) + location = cast(LocationBase, location) assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} From 30088a2322e4280acc091aa3e50009de761425c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 10:22:46 +0200 Subject: [PATCH 07/45] some fixes --- .../MarkerPopup/getTypedMarkerIcon.jsx | 65 +++++++------------ 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index e9e8e619..ecc77c98 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -5,30 +5,21 @@ import ReactDOMServer from 'react-dom/server'; const PIN_WIDTH = 72; const PIN_HEIGHT = 80; -const FALLBACK_COLOR = '#2a81cb'; // leaflet default marker blue +// Matches the accent color used elsewhere on the page (buttons, left panel). +const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || '#2a81cb'; -// Phosphor Icons (MIT, https://phosphoricons.com/) "map-pin-simple" glyph - -// a solid ball on a thin stem, not a balloon-style teardrop - reused as the -// pin body itself via CSS mask-image so we don't hand-draw/maintain our own -// pin shape - see PinIcon below. Its head is a solid circle (no cutout) -// centered at (50%, ~28%) of the box, so the glyph below sits inside that -// circle rather than fighting a hole like the balloon-pin design did. PIN_WIDTH -// is deliberately wider than the icon's native aspect ratio (mask-size 100% -// 100% stretches non-uniformly to fit) so the ball has real room for the -// glyph - the icon's own head is quite narrow relative to its height. +// Phosphor Icons "map-pin-simple" glyph (MIT, phosphoricons.com), masked as +// the pin body - see PinIcon below. PIN_WIDTH is wider than the icon's own +// aspect ratio so its ball has room for the glyph. const PIN_SHAPE_URL = 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/map-pin-simple-fill.svg'; -// The icon's own artwork doesn't reach the bottom of its 256x256 viewBox - -// there's blank margin below the stem's rounded tip (part of Phosphor's -// standard icon padding). Since the mask is stretched to fill the box -// exactly, that margin becomes real empty space at the bottom of our div - so -// the anchor Leaflet pins to the map coordinate has to target the actual -// rendered tip position, not the box's bottom edge, or the marker floats -// above its true location. Measured empirically (screenshot pixel-row of the -// last visible fill pixel) rather than computed from the path's raw -// coordinates, since drop-shadow/antialiasing shift the rendered edge a -// little from the raw path's numbers. +// The icon's artwork leaves blank margin below the stem tip, which becomes +// real empty space once stretched to fill the box - so the anchor has to +// target the actual rendered tip, not the box edge, or the marker floats +// above its true location. Measured empirically from a screenshot rather +// than the raw path coordinates, since drop-shadow/antialiasing shift the +// rendered edge slightly. const STEM_TIP_FRACTION = 0.8875; const GLYPH_SIZE = 24; @@ -46,11 +37,10 @@ const maskStyle = (url, color) => ({ }); /** - * Pin shape (Phosphor's map-pin-simple glyph, masked to `color`), optionally - * holding a glyph (`glyphUrl`, masked to white) inside its head, and an - * asterisk badge in the pin's own color scheme when `hasRemark` is set - so a - * remarked location keeps its type/color styling instead of being replaced by - * a plain, uncolored asterisk marker. + * Pin shape masked to `color`, optionally holding a glyph (`glyphUrl`) inside + * its head, and an asterisk badge when `hasRemark` is set - so a remarked + * location keeps its type/color styling instead of losing it to a plain + * asterisk marker. */ const PinIcon = ({ color, glyphUrl, hasRemark }) => (
@@ -104,24 +94,19 @@ PinIcon.propTypes = { }; /** - * Builds a Leaflet icon for `place` based on the deployment's marker styling - * lookup table (window.MARKER_STYLES, set server-side from the map's - * `marker_styles` config - see goodmap's db.get_marker_styles), or returns - * `null` when neither `icon_field` nor `color_field` produced a configured - * lookup match - callers should omit the `icon` prop in that case and fall - * back to Leaflet's default marker (or the plain asterisk icon for a remarked - * location with no marker_styles match) so unconfigured/legacy deployments - * are unchanged. When `place.has_remark` is set and a match *was* found, the - * returned icon carries an asterisk badge instead of losing its type/color - * styling to the plain asterisk marker. + * Builds a Leaflet icon for `place` from the deployment's marker styling + * lookup table (window.MARKER_STYLES, see goodmap's db.get_marker_styles), or + * `null` when neither `icon_field` nor `color_field` matched - callers should + * omit the `icon` prop then and fall back to Leaflet's default marker (or the + * plain asterisk icon for a remarked location). * * Expected shape of window.MARKER_STYLES: * { - * icon_field: 'type_of_place', // which location field selects the glyph - * color_field: 'status', // which location field selects the fill color - * icons: { parcel_locker: 'https://cdn.example.com/parcel-locker.svg' }, // field value -> icon URL, masked+tinted via CSS (see PinIcon) - * colors: { open: '#2e7d32' }, // field value -> fill color - * default_color: '#2a81cb', // fallback fill color + * icon_field: 'type_of_place', + * color_field: 'status', + * icons: { parcel_locker: 'https://cdn.example.com/parcel-locker.svg' }, + * colors: { open: '#2e7d32' }, + * default_color: '#2a81cb', * } * * @param {Object} place - Location data, as returned by GET /api/locations From 6b2bc2c08f4b8607ddb67bf5cb1b347d8108d8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 10:53:21 +0200 Subject: [PATCH 08/45] fixes --- e2e-tests/e2e_test_data_initial.json | 2 +- e2e-tests/tests/basic/test_marker_styles.py | 62 ++++++++++++------- .../MarkerPopup/getTypedMarkerIcon.jsx | 36 +++++------ .../MarkerPopup/getTypedMarkerIcon.test.jsx | 8 +-- 4 files changed, 61 insertions(+), 47 deletions(-) diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index 8af116ba..fc66933f 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -112,7 +112,7 @@ "is_free": "true", "speed_limit": "10", "amenities": [ - "benches" + "toilets" ], "uuid": "5986e755-1eaa-4121-a01c-4fef1d5d1da1" }, diff --git a/e2e-tests/tests/basic/test_marker_styles.py b/e2e-tests/tests/basic/test_marker_styles.py index 1eb2d47e..4c540ffe 100644 --- a/e2e-tests/tests/basic/test_marker_styles.py +++ b/e2e-tests/tests/basic/test_marker_styles.py @@ -12,14 +12,14 @@ from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, open_test_popup -# "big bridge" and "small bridge" each get their own Phosphor Icons (MIT) glyph - -# see e2e_test_data_initial.json's marker_styles.icons and getTypedMarkerIcon.jsx -# (icon URLs are CSS mask-image'd onto the pin, tinted by the matched color, -# rather than embedded as inline SVG path data). -BIG_BRIDGE_GLYPH_URL = ( +# "big bridge" and "small bridge" each get their own Phosphor Icons (MIT) type +# icon - see e2e_test_data_initial.json's marker_styles.icons and +# getTypedMarkerIcon.jsx (icon URLs are CSS mask-image'd onto the pin, tinted +# by the matched color, rather than embedded as inline SVG path data). +BIG_BRIDGE_TYPE_ICON_URL = ( "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" ) -SMALL_BRIDGE_GLYPH_URL = ( +SMALL_BRIDGE_TYPE_ICON_URL = ( "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/footprints-fill.svg" ) @@ -27,7 +27,7 @@ class TestMarkerStyles: """Test suite for marker_styles-driven pin icons/colors""" - def test_fast_bridge_marker_uses_type_glyph_and_red_speed_color(self, page: Page): + def test_fast_bridge_marker_uses_type_icon_and_red_speed_color(self, page: Page): """Pokoju (big bridge, speed_limit=50, no remark) is the only seeded bridge with all three of lighting+benches+toilets (amenities is an "and" category - see test_and_filter_within_category_narrows_results in test_map.py), so @@ -46,24 +46,38 @@ def test_fast_bridge_marker_uses_type_glyph_and_red_speed_color(self, page: Page # speed_limit=50's color. pin = marker.locator(".custom-typed-marker-pin") expect(pin).to_have_css("background-color", "rgb(198, 40, 40)") # #c62828 - # The type_of_place glyph, configured for "big bridge" - masked onto a div + # The type_of_place icon, configured for "big bridge" - masked onto a div # via CSS rather than embedded as an inline . - glyph = marker.locator(".custom-typed-marker-glyph") - expect(glyph).to_have_count(1) - expect(glyph).to_have_css("mask-image", f'url("{BIG_BRIDGE_GLYPH_URL}")') + type_icon = marker.locator(".custom-typed-marker-type-icon") + expect(type_icon).to_have_count(1) + expect(type_icon).to_have_css("mask-image", f'url("{BIG_BRIDGE_TYPE_ICON_URL}")') # No remark on Pokoju, so no asterisk badge. expect(marker.locator("span")).to_have_count(0) - # Note: a second real-browser color case (e.g. speed_limit=10 -> green) isn't - # covered here. The only speed=10 bridge without a remark (Piaskowy) can't be - # isolated to a standalone marker via the left panel's filters - its amenities - # ([benches]) are a subset of a remarked neighbor's (Tumski, [lighting, - # benches]) barely 230m away, so any filter combo that includes Piaskowy also - # includes Tumski, and Leaflet.markercluster groups them into one cluster - # bubble at the map's default zoom, hiding both individual markers. The - # color-lookup logic itself (arbitrary field values, including a "10" -> - # green case) is covered generically at the unit level in - # frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx. + def test_slow_bridge_marker_uses_type_icon_and_green_speed_color(self, page: Page): + """Piaskowy (small bridge, speed_limit=10, no remark, toilets) is the + only seeded speed<=10 bridge with toilets - the other two speed=10 + bridges (Zwierzyniecka, Tumski) have lighting/benches but neither has + toilets, so combining the speed_limit=10 radio with the toilets + checkbox isolates it without relying on clustering distance/zoom + assumptions. "cars" is unchecked first since Piaskowy is + pedestrians-only.""" + page.goto(BASE_URL, wait_until="domcontentloaded") + + page.get_by_role("checkbox", name="cars", exact=False).click() + page.get_by_role("radio", name="10 km/h", exact=False).click() + page.get_by_role("checkbox", name="toilets", exact=False).click() + + marker = page.locator(".custom-typed-marker-icon") + expect(marker).to_have_count(1, timeout=MARKER_LOAD_TIMEOUT) + + pin = marker.locator(".custom-typed-marker-pin") + expect(pin).to_have_css("background-color", "rgb(46, 125, 50)") # #2e7d32 (speed_limit=10) + type_icon = marker.locator(".custom-typed-marker-type-icon") + expect(type_icon).to_have_count(1) + expect(type_icon).to_have_css("mask-image", f'url("{SMALL_BRIDGE_TYPE_ICON_URL}")') + # No remark on Piaskowy, so no asterisk badge. + expect(marker.locator("span")).to_have_count(0) def test_remarked_bridge_keeps_type_and_color_styling_with_asterisk_badge(self, page: Page): """Zwierzyniecka has both a remark and marker_styles-matching fields @@ -84,7 +98,7 @@ def test_remarked_bridge_keeps_type_and_color_styling_with_asterisk_badge(self, pin = marker.locator(".custom-typed-marker-pin") expect(pin).to_have_css("background-color", "rgb(46, 125, 50)") # #2e7d32 (speed_limit=10) - glyph = marker.locator(".custom-typed-marker-glyph") - expect(glyph).to_have_count(1) - expect(glyph).to_have_css("mask-image", f'url("{SMALL_BRIDGE_GLYPH_URL}")') + type_icon = marker.locator(".custom-typed-marker-type-icon") + expect(type_icon).to_have_count(1) + expect(type_icon).to_have_css("mask-image", f'url("{SMALL_BRIDGE_TYPE_ICON_URL}")') expect(marker.locator("span")).to_have_text("*") diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index ecc77c98..301480fc 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -8,9 +8,9 @@ const PIN_HEIGHT = 80; // Matches the accent color used elsewhere on the page (buttons, left panel). const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || '#2a81cb'; -// Phosphor Icons "map-pin-simple" glyph (MIT, phosphoricons.com), masked as +// Phosphor Icons "map-pin-simple" shape (MIT, phosphoricons.com), masked as // the pin body - see PinIcon below. PIN_WIDTH is wider than the icon's own -// aspect ratio so its ball has room for the glyph. +// aspect ratio so its ball has room for the type icon. const PIN_SHAPE_URL = 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/map-pin-simple-fill.svg'; @@ -22,9 +22,9 @@ const PIN_SHAPE_URL = // rendered edge slightly. const STEM_TIP_FRACTION = 0.8875; -const GLYPH_SIZE = 24; -const GLYPH_OFFSET_TOP = 10; -const GLYPH_OFFSET_LEFT = 24; +const TYPE_ICON_SIZE = 24; +const TYPE_ICON_OFFSET_TOP = 10; +const TYPE_ICON_OFFSET_LEFT = 24; const maskStyle = (url, color) => ({ backgroundColor: color, @@ -37,12 +37,12 @@ const maskStyle = (url, color) => ({ }); /** - * Pin shape masked to `color`, optionally holding a glyph (`glyphUrl`) inside + * Pin shape masked to `color`, optionally holding a type icon (`typeIconUrl`) inside * its head, and an asterisk badge when `hasRemark` is set - so a remarked * location keeps its type/color styling instead of losing it to a plain * asterisk marker. */ -const PinIcon = ({ color, glyphUrl, hasRemark }) => ( +const PinIcon = ({ color, typeIconUrl, hasRemark }) => (
( ...maskStyle(PIN_SHAPE_URL, color), }} /> - {glyphUrl !== '' && ( + {typeIconUrl !== '' && (
)} @@ -89,7 +89,7 @@ const PinIcon = ({ color, glyphUrl, hasRemark }) => ( PinIcon.propTypes = { color: PropTypes.string.isRequired, - glyphUrl: PropTypes.string.isRequired, + typeIconUrl: PropTypes.string.isRequired, hasRemark: PropTypes.bool.isRequired, }; @@ -122,10 +122,10 @@ const getTypedMarkerIcon = place => { default_color: defaultColor, } = markerStyles; - const glyphUrl = icons?.[place[iconField]] || ''; + const typeIconUrl = icons?.[place[iconField]] || ''; const matchedColor = colors?.[place[colorField]] || ''; - if (!glyphUrl && !matchedColor) { + if (!typeIconUrl && !matchedColor) { return null; } @@ -133,7 +133,7 @@ const getTypedMarkerIcon = place => { html: ReactDOMServer.renderToString( , ), diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index 6074f9dc..2ac9de40 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -34,7 +34,7 @@ describe('getTypedMarkerIcon', () => { ).toBeNull(); }); - it('builds a DivIcon when the icon field matches a configured glyph', () => { + it('builds a DivIcon when the icon field matches a configured type icon', () => { setMarkerStyles(`{ "icon_field": "pointType", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } @@ -67,7 +67,7 @@ describe('getTypedMarkerIcon', () => { pointStatus: 'open', }); - // both the pin body (map-pin-fill.svg) and the glyph are CSS-masked + // both the pin body (map-pin-fill.svg) and the type icon are CSS-masked //
s tinted via background-color, not inline SVG , so // any icon set (not just single-path ones) works for either. expect(icon.options.html).toContain( @@ -77,7 +77,7 @@ describe('getTypedMarkerIcon', () => { expect(icon.options.html).not.toContain(' { + it('builds a DivIcon when the color field matches a configured color, with no type icon', () => { setMarkerStyles(`{ "color_field": "pointStatus", "colors": { "open": "#2e7d32" } @@ -117,7 +117,7 @@ describe('getTypedMarkerIcon', () => { }); expect(icon).not.toBeNull(); - expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); // keeps the type glyph + expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); // keeps the type icon expect(icon.options.html).toContain('>*'); // asterisk badge overlay }); From 049554d0795ff337fdd25b3d278a3d507786a6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 11:08:14 +0200 Subject: [PATCH 09/45] some trims --- .../MarkerPopup/getTypedMarkerIcon.jsx | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 301480fc..d515a2c6 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -5,24 +5,16 @@ import ReactDOMServer from 'react-dom/server'; const PIN_WIDTH = 72; const PIN_HEIGHT = 80; -// Matches the accent color used elsewhere on the page (buttons, left panel). -const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || '#2a81cb'; +const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || 'black'; -// Phosphor Icons "map-pin-simple" shape (MIT, phosphoricons.com), masked as -// the pin body - see PinIcon below. PIN_WIDTH is wider than the icon's own -// aspect ratio so its ball has room for the type icon. +// TODO make pin shape configurable const PIN_SHAPE_URL = 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/map-pin-simple-fill.svg'; -// The icon's artwork leaves blank margin below the stem tip, which becomes -// real empty space once stretched to fill the box - so the anchor has to -// target the actual rendered tip, not the box edge, or the marker floats -// above its true location. Measured empirically from a screenshot rather -// than the raw path coordinates, since drop-shadow/antialiasing shift the -// rendered edge slightly. -const STEM_TIP_FRACTION = 0.8875; - const TYPE_ICON_SIZE = 24; + +// Because the pin shape is not a perfectly aligned, anchor point is not at the bottom of the pin +// we need to adjust the anchor and popup positions accordingly const TYPE_ICON_OFFSET_TOP = 10; const TYPE_ICON_OFFSET_LEFT = 24; @@ -100,15 +92,6 @@ PinIcon.propTypes = { * omit the `icon` prop then and fall back to Leaflet's default marker (or the * plain asterisk icon for a remarked location). * - * Expected shape of window.MARKER_STYLES: - * { - * icon_field: 'type_of_place', - * color_field: 'status', - * icons: { parcel_locker: 'https://cdn.example.com/parcel-locker.svg' }, - * colors: { open: '#2e7d32' }, - * default_color: '#2a81cb', - * } - * * @param {Object} place - Location data, as returned by GET /api/locations * @returns {import('leaflet').DivIcon|null} */ From d0634390ffabdfd95599853949168ddcf254a48a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 11:26:18 +0200 Subject: [PATCH 10/45] some fixes --- .../MarkerPopup/getTypedMarkerIcon.jsx | 17 +++++++++++++---- .../MarkerPopup/getTypedMarkerIcon.test.jsx | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index d515a2c6..dcac3dfc 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -2,15 +2,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import { DivIcon } from 'leaflet'; import ReactDOMServer from 'react-dom/server'; +// Phosphor Icons "map-pin-simple" (fill style), MIT license, phosphoricons.com - +// vendored locally (see the .svg file) instead of fetched from a CDN, since +// it's a fixed asset we chose, not deployment config, and every styled marker +// on every deployment depends on it. +// TODO make pin shape configurable +import PIN_SHAPE_URL from '../../res/svg/marker-pin.svg'; const PIN_WIDTH = 72; const PIN_HEIGHT = 80; const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || 'black'; -// TODO make pin shape configurable -const PIN_SHAPE_URL = - 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/map-pin-simple-fill.svg'; - const TYPE_ICON_SIZE = 24; // Because the pin shape is not a perfectly aligned, anchor point is not at the bottom of the pin @@ -18,6 +20,13 @@ const TYPE_ICON_SIZE = 24; const TYPE_ICON_OFFSET_TOP = 10; const TYPE_ICON_OFFSET_LEFT = 24; +// The pin's own artwork doesn't reach the bottom of its viewBox, so the +// anchor Leaflet pins to the map coordinate has to target the actual +// rendered tip, not the box edge, or the marker floats above its true +// location. Measured empirically from a screenshot rather than the raw path +// coordinates, since drop-shadow/antialiasing shift the rendered edge a bit. +const STEM_TIP_FRACTION = 0.8875; + const maskStyle = (url, color) => ({ backgroundColor: color, WebkitMaskImage: `url(${url})`, diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index 2ac9de40..03dec5b6 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -48,7 +48,7 @@ describe('getTypedMarkerIcon', () => { expect(icon).not.toBeNull(); expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); - expect(icon.options.html).toContain('#2a81cb'); // fallback color, no color_field set + expect(icon.options.html).toContain('background-color:black'); // fallback color, no color_field set expect(icon.options.iconSize).toEqual([72, 80]); }); From 3e198199691a88e4d8e58b7ca2cbc5b1ac6b505e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 12:03:08 +0200 Subject: [PATCH 11/45] added missing marker --- frontend/src/res/svg/marker-pin.svg | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 frontend/src/res/svg/marker-pin.svg diff --git a/frontend/src/res/svg/marker-pin.svg b/frontend/src/res/svg/marker-pin.svg new file mode 100644 index 00000000..59e03460 --- /dev/null +++ b/frontend/src/res/svg/marker-pin.svg @@ -0,0 +1,2 @@ + + From e2896450cb7b400848f53e07b394372461438339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 12:41:29 +0200 Subject: [PATCH 12/45] cleanup --- e2e-tests/tests/basic/test_marker_styles.py | 18 ++--- .../components/MarkerPopup/MarkerPopup.jsx | 31 +++------ .../MarkerPopup/getTypedMarkerIcon.jsx | 63 ++++++++---------- frontend/src/res/img/marker-icon-asterisk.png | Bin 6212 -> 0 bytes frontend/src/res/svg/marker-pin.svg | 3 +- .../tests/MarkerPopup/MarkerPopup.test.jsx | 21 +++--- .../MarkerPopup/getTypedMarkerIcon.test.jsx | 17 ++++- 7 files changed, 72 insertions(+), 81 deletions(-) delete mode 100644 frontend/src/res/img/marker-icon-asterisk.png diff --git a/e2e-tests/tests/basic/test_marker_styles.py b/e2e-tests/tests/basic/test_marker_styles.py index 4c540ffe..62e3f200 100644 --- a/e2e-tests/tests/basic/test_marker_styles.py +++ b/e2e-tests/tests/basic/test_marker_styles.py @@ -4,8 +4,8 @@ Tests that the map picks pin icon/color per marker_styles (icon_field: type_of_place, color_field: speed_limit - see e2e_test_data_initial.json), and that a location with both a remark and a marker_styles match keeps its -type/color styling with an asterisk badge overlay, rather than losing it to -the plain asterisk icon (see getTypedMarkerIcon.jsx/MarkerPopup.jsx). +type/color styling with an asterisk badge overlay, rather than losing it to a +plain, unstyled asterisk badge (see getTypedMarkerIcon.jsx/MarkerPopup.jsx). """ from playwright.sync_api import Page, expect @@ -82,12 +82,14 @@ def test_slow_bridge_marker_uses_type_icon_and_green_speed_color(self, page: Pag def test_remarked_bridge_keeps_type_and_color_styling_with_asterisk_badge(self, page: Page): """Zwierzyniecka has both a remark and marker_styles-matching fields (small bridge, speed_limit=10) - it should render its normal typed/colored - pin plus an asterisk badge, not fall back to the plain asterisk icon - (every type_of_place/speed_limit value happens to be covered by - marker_styles in this seeded dataset, so that plain-icon fallback path - isn't exercised here - it's covered at the unit level instead, see - getTypedMarkerIcon.test.jsx's "falls back to the plain asterisk icon" - case).""" + pin plus an asterisk badge, not fall back to our own pin in the plain + fallback color with no type icon (every type_of_place/speed_limit value + happens to be covered by marker_styles in this seeded dataset, so that + fallback-color path isn't exercised here - it's covered at the unit + level instead, see getTypedMarkerIcon.test.jsx's "returns our own pin in + the fallback color with just the badge" case). Also guards against ever + reintroducing the old PNG-based asterisk icon this replaced. + """ page.goto(BASE_URL, wait_until="domcontentloaded") open_test_popup(page) diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index b18e5710..558a13c8 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Marker } from 'react-leaflet'; import { isMobile } from 'react-device-detect'; -import { Icon } from 'leaflet'; import { useTranslation } from 'react-i18next'; import httpService from '../../services/http/httpService'; import useMapStore from '../Map/store/map.store'; @@ -11,7 +10,6 @@ import LocationDetailsBox from './LocationDetails'; import MobilePopup from './MobilePopup'; import DesktopPopup from './DesktopPopup'; import getTypedMarkerIcon from './getTypedMarkerIcon'; -import iconAsterisk from '../../res/img/marker-icon-asterisk.png'; /** * Wrapper component that fetches full location details and renders them in a popup. @@ -70,17 +68,6 @@ LocationDetailsBoxWrapper.propTypes = { }).isRequired, }; -/** - * Custom Leaflet icon for markers with remarks/special annotations. - * Displays an asterisk icon to visually distinguish remarked locations from standard markers. - */ -const asteriskIcon = new Icon({ - iconUrl: iconAsterisk, - iconSize: [40, 48], // size of the icon - iconAnchor: [19, 46], // point of the icon which will correspond to marker's location - popupAnchor: [0, -40], // point from which the popup should open relative to the iconAnchor -}); - /** * Interactive map marker component that displays location details in a popup when clicked. * Supports special visual indication for locations with remarks using an asterisk icon. @@ -88,7 +75,7 @@ const asteriskIcon = new Icon({ * @param {Object} props - Component props * @param {Object} props.place - Location data object * @param {number[]} props.place.position - Coordinates [latitude, longitude] - * @param {boolean} [props.place.has_remark] - Whether this location has a remark (uses asterisk icon if true) + * @param {boolean} [props.place.has_remark] - Whether this location has a remark (adds an asterisk badge if true) * @returns {React.ReactElement} Leaflet Marker component with click-to-show-details functionality */ const MarkerPopup = ({ place }) => { @@ -120,20 +107,20 @@ const MarkerPopup = ({ place }) => { eventHandlers: { click: handleMarkerClick, }, - alt: place.has_remark ? 'Marker-Asterisk' : 'Marker', + // getTypedMarkerIcon renders as a
, not an , so 'alt' has no + // visible effect once it returns an icon - kept as plain text for the + // one case it still applies to: Leaflet's own default marker below. + alt: 'Marker', }; - // Prefer a marker_styles match (getTypedMarkerIcon adds an asterisk badge to - // it when place.has_remark is set, so a remarked location keeps its type/color - // styling) and only fall back to the plain asterisk icon when there's no - // match to style - e.g. a legacy/unconfigured deployment. Only add an icon - // prop when we actually have a custom icon: passing icon={undefined} causes + // getTypedMarkerIcon returns our own pin whenever there's a marker_styles + // match or a remark to badge, null only for a plain, unremarked location - + // which then falls back to Leaflet's default marker. Only add an icon prop + // when we actually have a custom icon: passing icon={undefined} causes // errors in MarkerClusterGroup during cluster zoom animations. const typedIcon = getTypedMarkerIcon(place); if (typedIcon) { markerProps.icon = typedIcon; - } else if (place.has_remark) { - markerProps.icon = asteriskIcon; } return ( diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index dcac3dfc..0cf301ed 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -2,30 +2,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import { DivIcon } from 'leaflet'; import ReactDOMServer from 'react-dom/server'; -// Phosphor Icons "map-pin-simple" (fill style), MIT license, phosphoricons.com - -// vendored locally (see the .svg file) instead of fetched from a CDN, since -// it's a fixed asset we chose, not deployment config, and every styled marker -// on every deployment depends on it. -// TODO make pin shape configurable +// Custom balloon pin (sharp point, solid head, no third-party asset/CDN) - see +// the .svg file. Its point sits exactly on the viewBox's bottom edge, so the +// anchor below doesn't need any empirical correction the way a borrowed icon +// with its own padding would. import PIN_SHAPE_URL from '../../res/svg/marker-pin.svg'; -const PIN_WIDTH = 72; -const PIN_HEIGHT = 80; +const PIN_WIDTH = 36; +const PIN_HEIGHT = 40; const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || 'black'; -const TYPE_ICON_SIZE = 24; - -// Because the pin shape is not a perfectly aligned, anchor point is not at the bottom of the pin -// we need to adjust the anchor and popup positions accordingly -const TYPE_ICON_OFFSET_TOP = 10; -const TYPE_ICON_OFFSET_LEFT = 24; - -// The pin's own artwork doesn't reach the bottom of its viewBox, so the -// anchor Leaflet pins to the map coordinate has to target the actual -// rendered tip, not the box edge, or the marker floats above its true -// location. Measured empirically from a screenshot rather than the raw path -// coordinates, since drop-shadow/antialiasing shift the rendered edge a bit. -const STEM_TIP_FRACTION = 0.8875; +const TYPE_ICON_SIZE = 16; +const TYPE_ICON_OFFSET_TOP = 6.5; +const TYPE_ICON_OFFSET_LEFT = 10; const maskStyle = (url, color) => ({ backgroundColor: color, @@ -38,10 +27,10 @@ const maskStyle = (url, color) => ({ }); /** - * Pin shape masked to `color`, optionally holding a type icon (`typeIconUrl`) inside - * its head, and an asterisk badge when `hasRemark` is set - so a remarked - * location keeps its type/color styling instead of losing it to a plain - * asterisk marker. + * Pin shape masked to `color`, optionally holding a type icon (`typeIconUrl`) + * inside its head, and an asterisk badge when `hasRemark` is set - so a + * remarked location keeps its type/color styling (or just its fallback color, + * if nothing else matched) instead of losing it to an unrelated asterisk icon. */ const PinIcon = ({ color, typeIconUrl, hasRemark }) => (
@@ -71,9 +60,9 @@ const PinIcon = ({ color, typeIconUrl, hasRemark }) => ( { const typeIconUrl = icons?.[place[iconField]] || ''; const matchedColor = colors?.[place[colorField]] || ''; + const hasRemark = Boolean(place.has_remark); - if (!typeIconUrl && !matchedColor) { + if (!typeIconUrl && !matchedColor && !hasRemark) { return null; } @@ -126,13 +117,13 @@ const getTypedMarkerIcon = place => { , ), className: 'custom-typed-marker-icon', iconSize: [PIN_WIDTH, PIN_HEIGHT], - iconAnchor: [PIN_WIDTH / 2, PIN_HEIGHT * STEM_TIP_FRACTION], - popupAnchor: [0, -PIN_HEIGHT * STEM_TIP_FRACTION], + iconAnchor: [PIN_WIDTH / 2, PIN_HEIGHT], + popupAnchor: [0, -PIN_HEIGHT], }); }; diff --git a/frontend/src/res/img/marker-icon-asterisk.png b/frontend/src/res/img/marker-icon-asterisk.png deleted file mode 100644 index 9e5cf850e9f60b0d4b7ec9cda1d9d907aecc7f1f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6212 zcmeHKdpOiv_a8%a+(I1@)ifuU)ZAy7nP!MlOc{wOx5Rg5zQfeq%#2$q5+a1?%1IY> zQ0YP!L?o9E5_Kpll1_Ksqa3{78M^&`@AJG*&-1?jHS=tHeb-*=v)B6Uwb%G2d$_yk zY0uDx!C-n!S7%RXwuT0FvL^JG8%@rErt6>&lh0(@z$QZvkfoXiObwEhc4`wc7Lws= z>M(U^PK1ULBr9!{V}{x!)tm~+W)pHYBwJ6|z#$o@28ZcEb1pRSkUR~Vi=ZLcI=)vG zBr89YRbN*~QX+qzU6?En1RjmUq6uWkAB!hZu>>jshrna0SP~V7gRIpKe2qwnBw)vn zK`8M82E&8NU?8M2311)tVX(;DjhS@U+C}Ddf&NF3zZxz~n#?b7H+Ec1?Xa14(5c{8 zzc6gsJDq*Rx%1H57xsddw64W0B28!A)YEO;r8*=}zk!B<-u zH0%}oTrs;+lC$etR35o`=?p%O0P~3rHcd6xDR4^YAF3JajX2K-*8j=Uwul{=-aTrf9#^rbMBLreaPBTm z4c=P4v*Uxe*`nuJ@7DXLn5&I+SJ#F4XAQHPT)d-vI~x?wxZ)%5i@zea*5qc2wcQF1<}we-`l8sd@cxgY1xey9c`1#BOM56g|p zm59&)Pr?Dwks_(`z+g0oNGZTw2PzO8Fjyd_BVSfrLLvk_I?|iO!m^|cFht*z=ysE!yC8HgaF ziD)cpaiky|kF?iD&}2M5)zf*&1O)U%M}{aAQYr=$5fOooAfP3(U<{5zpy!hiRHg3A;vMBK{;0@kSYWcF+zz6a3o<0IuZ%3BREBWL$7@I4D;9_ALaD`^8@xCKIaC z;c+pb5EMbCa%flFcUvlN+!qfe1;GN5ROJPc{hg&k!2e3tcd;o~ROx&h2;}|+_dDy) zxvPvJD;A6DEa8SJ!(%$rk;?t4JPB98qpCy_hYwK5wqz8UfVD*tDOfxTz~O9B1UoDN zAll)vBqIJBD5hAh0K{BS2?c?p1rQEs$LI10I39|GExi z<5HJMWFi1MPJsvr1~F1`uxdd`IMvaENk`(**smH7A)w$x4s@iOKpYnNb%8AqfnEwg z$tKQ@NFWl41RRb;w#8Gv@m>YW%;>TfHR-~AoS**2>F+ph5P~ zk-G}y(Ed>>)%cC_0z=22#vg?O)m1_uRF{GZaK}@S1L5E}!w}Z^5;p`82ZK=em?+oJ zdcnUb1rpDeYzx@gqCg6lh$8as2q-e%j*Q{~WIH~VjK@-Vpo)br=yC~P5dp|R$6$y@ zh%2Z(RliUOYt=!```$Vt1XP{^L>LN7KoM~NmoUsk!Wd=G_?$5f^Zz)ZsSLhpGLYZ6 z4r*RdFT{Llh7+7Y@%}GA6KCqpqL;nRuKrc&!ZPp>si-E72G7TY~~zUl;SBAScGVZa%_`Ed$$g4wqT`(xXv@$mCZ?x z7anMYR|MPwg2VX(4P}pVJ7<>!nAnF0N7TPzEPrE}72O*Bi1Kpy;_Si7>aotDb)%mc z>COIc{~nYm(tDgdnjRJ38}x+*t!{=tDsd@}Xby~d+&NIuUP?p0lF z`rv9o_gOsb?Dkvk9bk=x*&sUTR8{SwrsRU(b!PeHnt{4@T|=p_)eOQ-56#?~_pz_S zuC(7v%Q&>@wpMRyTQYz30xW*$Q(?>CC7fV7X7v92+_TMoEhZxcl&phn>cgWOW}mxY zB?y29ncWs1BLsK2 zHH76_MaqXyE7LFuBeWsgncl4>O`8au`P8!?<dw+>b|H7<%GBhg};ib>DmW zkF%_^>deXcnrVl3hiiE1GthSVG6rKt{f5KC4S^pYkSL)u8;9tvX#eOF_Qyz*gZiKB zC3O4K91@)J59t0Sj7lMtMj9U?JNJCl0S|tRW8};#_ec?o&>ga1xmW#L+U&9&&WCgx zJ$6alYjwP`x1wSMN@IiFew6YE2}@7;Ul zfwiR9$InijdD&1pLPQ%s4C{YHy__Jg9J)*|dG+ed;tO2Ash<-692&WQV~8;*-~~53 zb-ngTeP6I<4tvkN>_h`yOWlRAizLBx!qJ5RJ&fbJx@`qg&3s|v4s+n9dW^MP=k5%-hE3bF{aylSAOy|z2)9z#Too-`dsH_0hk15!_=ArpDTt9 zhExYzz1+LG^?A7t>Nff{rpMZsPpf(F32SQ)^}4x-p0lEG>61gp{7W-$W+7grv49@K z*cGsYO9Rc|8bPkZ?~m?{=(CRPJ#GWKimHWET$WEsE!^;~QZ3_EMo3Zn^xQey;jxc> z0$_Si)I&X%F1S`io@S}Jrf3p5GcrWnQ2ym~VX1D8bV-e&3udQNz>UhffdBw#p14$gD31-iw;jv+?7}MxJDAd;+%k z8a$40jr{{Gvx?aM%BDi`+C@P<> zpLN9pANMe__go6r)@1w5u)FRllA3I-Lh0-0nK7gPWLkuEa`0D|r<7G)FCm+F)LAg9 zrTN$McAIR|K<=vXn2YtuXxyruSTsn0?XSbpBJJat`%e~5ogvwB<~ZH;5LsKhYTX|z zs)~dGmB=HZtimdwD>)8cx-F%uc958#LpdFm5RbdG;E|S7kMYVU9ON6v5%fcJeDj{^ zC&gpW2DFls1FzIMiu+*m{9|=37woq{Y%$_xJThAEQv1mfMy?V)9LAQO$X;l6)O^iO z{941<3>o>)vXVKvB1&21{7#Qp7~)`FmgD|o1@Wcam6u9=Q z%qHtvG$kKtIhS0ub@}9=hwn<~U4+(-JUE!64U05p>{&P!&oHxETggo8VTX*kH6h&N zX!r-x(^r3Wjia%R5^T<#U(UaR2V$+(_#49iHuZh1w{h!cTsUI@SKoWvJakmpnAG9@ z&R!#S=Tpw5{2c{*G%_R;hnaNaVD2hZ@99itzWxQrtjC}*lBpFIQRou z5PR2u)r<_^6FQZd61j9u6s|9~ri83GXvg7o-1L*1=^Mak%b(+yyf5rU?pwWLulWUd z5e>#==RHaec<(NG2EPS0b0Bp;u}&vbqE=kn>mNJs_Y}(EmGAvdIx-ZTh=|HJm}5~N z8ZyiE*qibcukT!a&^@c-nBm)(nj5TuD?zLFwwLY6*4Wv)OtYkDMWDuv52k$s#kwv!%!IZ-p#^=ZmjAFQQw - + diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index 9689d6ca..c43dfaac 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -105,7 +105,7 @@ describe('MarkerPopup with remark', () => { globalThis.fetch.mockRestore(); }); - it('should render marker popup with asterisks when remark is true', () => { + it('should render our own pin with an asterisk badge when remark is true', () => { // eslint-disable-next-line camelcase -- matches backend API schema property name const locationWhenRemarkIsTrue = { ...location, has_remark: true }; act(() => { @@ -122,7 +122,9 @@ describe('MarkerPopup with remark', () => { , ); }); - expect(screen.getByAltText(/Marker-Asterisk/i)).toBeInTheDocument(); + const marker = document.querySelector('.custom-typed-marker-icon'); + expect(marker).toBeInTheDocument(); + expect(marker.querySelector('span')).toHaveTextContent('*'); }); it('should pass custom icon prop when remark is true', () => { @@ -140,15 +142,14 @@ describe('MarkerPopup with remark', () => { ); }); - const marker = screen.getByAltText(/Marker-Asterisk/i); - const leafletMarker = marker.closest('.leaflet-marker-icon'); + const marker = document.querySelector('.custom-typed-marker-icon'); - // When remark is true, marker should have custom asterisk icon - expect(leafletMarker).toBeInTheDocument(); + // When remark is true, marker should have our own pin, not Leaflet's default icon + expect(marker).toBeInTheDocument(); - // Verify custom asterisk icon dimensions (40x48) are applied - const style = window.getComputedStyle(leafletMarker); - expect(style.width).toBe('40px'); // asteriskIcon width - expect(style.height).toBe('48px'); // asteriskIcon height + // Verify our pin's dimensions (36x40) are applied, not Leaflet's default (25x41) + const style = window.getComputedStyle(marker); + expect(style.width).toBe('36px'); + expect(style.height).toBe('40px'); }); }); diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index 03dec5b6..cd1cc7e8 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -49,7 +49,7 @@ describe('getTypedMarkerIcon', () => { expect(icon).not.toBeNull(); expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); expect(icon.options.html).toContain('background-color:black'); // fallback color, no color_field set - expect(icon.options.iconSize).toEqual([72, 80]); + expect(icon.options.iconSize).toEqual([36, 40]); }); it('masks the icon URL through CSS so it picks up the matched color, instead of embedding SVG path data', () => { @@ -136,10 +136,21 @@ describe('getTypedMarkerIcon', () => { expect(icon.options.html).not.toContain('>*'); }); - it('returns null (falls back to the plain asterisk icon) when has_remark is set but nothing matches', () => { + it('returns our own pin in the fallback color with just the badge when has_remark is set but nothing matches', () => { setMarkerStyles('{}'); - expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50], has_remark: true })).toBeNull(); + const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], has_remark: true }); + + expect(icon).not.toBeNull(); + expect(icon.options.html).toContain('background-color:black'); // fallback color + expect(icon.options.html).toContain('>*'); + expect(icon.options.html).not.toContain('custom-typed-marker-type-icon'); + }); + + it('still returns null when there is neither a match nor a remark to show', () => { + setMarkerStyles('{}'); + + expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50] })).toBeNull(); }); it('uses default_color from MARKER_STYLES when the matched value has no color entry', () => { From 4ff6c8ac92efb3448ba5b0e63c3ec4b66233e784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 12:48:26 +0200 Subject: [PATCH 13/45] cleanup --- e2e-tests/e2e_test_data_initial.json | 3 +-- .../MarkerPopup/getTypedMarkerIcon.jsx | 26 ++++++++----------- .../tests/MarkerPopup/MarkerPopup.test.jsx | 6 ++--- .../MarkerPopup/getTypedMarkerIcon.test.jsx | 7 ++--- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index fc66933f..a0c95225 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -283,8 +283,7 @@ "10": "#2e7d32", "30": "#ef6c00", "50": "#c62828" - }, - "default_color": "#2a81cb" + } }, "visible_data": [ "remark", diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 0cf301ed..a703841a 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -8,13 +8,15 @@ import ReactDOMServer from 'react-dom/server'; // with its own padding would. import PIN_SHAPE_URL from '../../res/svg/marker-pin.svg'; -const PIN_WIDTH = 36; -const PIN_HEIGHT = 40; +const PIN_WIDTH = 45; +const PIN_HEIGHT = 50; +// The marker's default color (used whenever color_field doesn't match) is +// always the page's own secondary color, not a separately configurable value. const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || 'black'; -const TYPE_ICON_SIZE = 16; -const TYPE_ICON_OFFSET_TOP = 6.5; -const TYPE_ICON_OFFSET_LEFT = 10; +const TYPE_ICON_SIZE = 20; +const TYPE_ICON_OFFSET_TOP = 8; +const TYPE_ICON_OFFSET_LEFT = 12; const maskStyle = (url, color) => ({ backgroundColor: color, @@ -61,8 +63,8 @@ const PinIcon = ({ color, typeIconUrl, hasRemark }) => ( style={{ position: 'absolute', top: 1, - left: 19, - fontSize: 17, + left: 24, + fontSize: 21, fontWeight: 'bold', lineHeight: 1, color: '#ffffff', @@ -96,13 +98,7 @@ PinIcon.propTypes = { */ const getTypedMarkerIcon = place => { const markerStyles = globalThis.MARKER_STYLES || {}; - const { - icon_field: iconField, - color_field: colorField, - icons, - colors, - default_color: defaultColor, - } = markerStyles; + const { icon_field: iconField, color_field: colorField, icons, colors } = markerStyles; const typeIconUrl = icons?.[place[iconField]] || ''; const matchedColor = colors?.[place[colorField]] || ''; @@ -115,7 +111,7 @@ const getTypedMarkerIcon = place => { return new DivIcon({ html: ReactDOMServer.renderToString( , diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index c43dfaac..5beab11b 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -147,9 +147,9 @@ describe('MarkerPopup with remark', () => { // When remark is true, marker should have our own pin, not Leaflet's default icon expect(marker).toBeInTheDocument(); - // Verify our pin's dimensions (36x40) are applied, not Leaflet's default (25x41) + // Verify our pin's dimensions (45x50) are applied, not Leaflet's default (25x41) const style = window.getComputedStyle(marker); - expect(style.width).toBe('36px'); - expect(style.height).toBe('40px'); + expect(style.width).toBe('45px'); + expect(style.height).toBe('50px'); }); }); diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index cd1cc7e8..9715e681 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -49,7 +49,7 @@ describe('getTypedMarkerIcon', () => { expect(icon).not.toBeNull(); expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); expect(icon.options.html).toContain('background-color:black'); // fallback color, no color_field set - expect(icon.options.iconSize).toEqual([36, 40]); + expect(icon.options.iconSize).toEqual([45, 50]); }); it('masks the icon URL through CSS so it picks up the matched color, instead of embedding SVG path data', () => { @@ -153,7 +153,7 @@ describe('getTypedMarkerIcon', () => { expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50] })).toBeNull(); }); - it('uses default_color from MARKER_STYLES when the matched value has no color entry', () => { + it('ignores a configured default_color and uses the page fallback color instead', () => { setMarkerStyles(`{ "icon_field": "pointType", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, @@ -166,6 +166,7 @@ describe('getTypedMarkerIcon', () => { pointType: 'parcelLocker', }); - expect(icon.options.html).toContain('#123456'); + expect(icon.options.html).not.toContain('#123456'); + expect(icon.options.html).toContain('background-color:black'); }); }); From 0c3ee6a4e74d18c13bfbdac606c5301c7cd85e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 12:52:11 +0200 Subject: [PATCH 14/45] cleanup comment --- frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index 9715e681..abf79e98 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -67,7 +67,7 @@ describe('getTypedMarkerIcon', () => { pointStatus: 'open', }); - // both the pin body (map-pin-fill.svg) and the type icon are CSS-masked + // both the pin body (our own marker-pin.svg) and the type icon are CSS-masked //
s tinted via background-color, not inline SVG , so // any icon set (not just single-path ones) works for either. expect(icon.options.html).toContain( From 3597a6636527be8027c691302da920cc9d95c3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 12:58:16 +0200 Subject: [PATCH 15/45] more human readable icons --- tests/unit_tests/test_db.py | 39 ++++++++++++++++++++------------ tests/unit_tests/test_goodmap.py | 4 +++- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/tests/unit_tests/test_db.py b/tests/unit_tests/test_db.py index 29616092..9759c97c 100644 --- a/tests/unit_tests/test_db.py +++ b/tests/unit_tests/test_db.py @@ -308,7 +308,9 @@ def test_json_file_db_get_meta_data_empty(): "marker_styles": { "icon_field": "type_of_place", "color_field": "status", - "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "icons": { + "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" + }, "colors": {"open": "#2e7d32"}, } } @@ -322,7 +324,9 @@ def test_json_file_db_get_marker_styles(): assert result == { "icon_field": "type_of_place", "color_field": "status", - "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "icons": { + "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" + }, "colors": {"open": "#2e7d32"}, } @@ -377,23 +381,26 @@ def test_google_json_db_get_meta_data_empty(mock_cli): @mock.patch("platzky.db.google_json_db.Client") def test_google_json_db_get_marker_styles(mock_cli): - mock_cli.return_value.bucket.return_value.blob.return_value.download_as_text.return_value = ( - json.dumps( - { - "map": { - "marker_styles": { - "icon_field": "type_of_place", - "icons": {"parcel_locker": "M0 0h16v16H0z"}, - } + blob = mock_cli.return_value.bucket.return_value.blob.return_value + blob.download_as_text.return_value = json.dumps( + { + "map": { + "marker_styles": { + "icon_field": "type_of_place", + "icons": { + "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" + }, } } - ) + } ) db = GoogleJsonDb("bucket", "blob") result = google_json_db_get_marker_styles(db) assert result == { "icon_field": "type_of_place", - "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "icons": { + "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" + }, } @@ -1170,7 +1177,9 @@ def test_mongodb_db_get_marker_styles(mock_client): "_id": "map_config", "marker_styles": { "icon_field": "type_of_place", - "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "icons": { + "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" + }, }, } @@ -1178,7 +1187,9 @@ def test_mongodb_db_get_marker_styles(mock_client): result = mongodb_db_get_marker_styles(db) assert result == { "icon_field": "type_of_place", - "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "icons": { + "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" + }, } diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index f8a4766d..0b89fa1d 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -122,7 +122,9 @@ def test_map_route_includes_marker_styles(): "categories": {"type_of_place": ["parcel_locker", "container"]}, "marker_styles": { "icon_field": "type_of_place", - "icons": {"parcel_locker": "M0 0h16v16H0z"}, + "icons": { + "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" + }, "colors": {}, }, }, From 3457d332b3e53c691829ba6c2a8c11da4a091fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 14:25:05 +0200 Subject: [PATCH 16/45] some cleanup --- frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index a703841a..96b04b3a 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -2,10 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { DivIcon } from 'leaflet'; import ReactDOMServer from 'react-dom/server'; -// Custom balloon pin (sharp point, solid head, no third-party asset/CDN) - see -// the .svg file. Its point sits exactly on the viewBox's bottom edge, so the -// anchor below doesn't need any empirical correction the way a borrowed icon -// with its own padding would. import PIN_SHAPE_URL from '../../res/svg/marker-pin.svg'; const PIN_WIDTH = 45; From d2096e47a1b23df720d8e08d7fe0c63a806ee045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 14:38:53 +0200 Subject: [PATCH 17/45] cleanup --- frontend/src/components/MarkerPopup/MarkerPopup.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index 558a13c8..a2844699 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -107,10 +107,6 @@ const MarkerPopup = ({ place }) => { eventHandlers: { click: handleMarkerClick, }, - // getTypedMarkerIcon renders as a
, not an , so 'alt' has no - // visible effect once it returns an icon - kept as plain text for the - // one case it still applies to: Leaflet's own default marker below. - alt: 'Marker', }; // getTypedMarkerIcon returns our own pin whenever there's a marker_styles From 121cd64e2f7e119c79c91cb85f28ea04be7ebe9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 14:39:20 +0200 Subject: [PATCH 18/45] cleanup comments --- frontend/src/components/MarkerPopup/MarkerPopup.jsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index a2844699..9269c777 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -109,11 +109,6 @@ const MarkerPopup = ({ place }) => { }, }; - // getTypedMarkerIcon returns our own pin whenever there's a marker_styles - // match or a remark to badge, null only for a plain, unremarked location - - // which then falls back to Leaflet's default marker. Only add an icon prop - // when we actually have a custom icon: passing icon={undefined} causes - // errors in MarkerClusterGroup during cluster zoom animations. const typedIcon = getTypedMarkerIcon(place); if (typedIcon) { markerProps.icon = typedIcon; From 56d1b13d99970fd861a709bbca828b58d283eebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 14:51:36 +0200 Subject: [PATCH 19/45] cleanup --- tests/unit_tests/test_goodmap.py | 36 ++++++++++---------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 0b89fa1d..7d3e654b 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -107,11 +107,13 @@ def test_frontend_lib_url_uses_bundled_static_when_present(): assert 'src="/static/frontend/index.min.js"' in response.data.decode("utf-8") -def test_map_route_includes_marker_styles(): +def test_map_route_marker_styles(): """The frontend picks pin icon/color per marker_styles.config's iconField/colorField at runtime from window.MARKER_STYLES - a deployment-specific lookup table that lives - in the database (like categories/visible_data), not hardcoded in the frontend build.""" - config = GoodmapConfig( + in the database (like categories/visible_data), not hardcoded in the frontend build. + Deployments that don't configure it get an empty object instead, so the frontend + falls back to Leaflet's default marker - no behavior change.""" + configured_config = GoodmapConfig( APP_NAME="test_app", SECRET_KEY="test_secret", USE_WWW=False, @@ -131,11 +133,10 @@ def test_map_route_includes_marker_styles(): TYPE="json", ), ) - app = goodmap.create_app_from_config(config) - app.config["WTF_CSRF_ENABLED"] = False # NOSONAR - client = app.test_client() + configured_app = goodmap.create_app_from_config(configured_config) + configured_app.config["WTF_CSRF_ENABLED"] = False # NOSONAR - response = client.get("/map") + response = configured_app.test_client().get("/map") assert response.status_code == 200 response_text = response.data.decode("utf-8") @@ -143,25 +144,10 @@ def test_map_route_includes_marker_styles(): assert "icon_field" in response_text assert "parcel_locker" in response_text + unconfigured_app = goodmap.create_app_from_config(_minimal_config()) + unconfigured_app.config["WTF_CSRF_ENABLED"] = False # NOSONAR -def test_map_route_marker_styles_defaults_to_empty(): - """Deployments that don't configure marker_styles get an empty object, so the - frontend falls back to Leaflet's default marker - no behavior change.""" - config = GoodmapConfig( - APP_NAME="test_app", - SECRET_KEY="test_secret", - USE_WWW=False, - BLOG_PREFIX="/blog", - DB=JsonDbConfig( - DATA={"site_content": {"pages": []}, "categories": {}}, - TYPE="json", - ), - ) - app = goodmap.create_app_from_config(config) - app.config["WTF_CSRF_ENABLED"] = False # NOSONAR - client = app.test_client() - - response = client.get("/map") + response = unconfigured_app.test_client().get("/map") assert response.status_code == 200 assert "window.MARKER_STYLES={};" in response.data.decode("utf-8") From d62f19d4ff30a17476a3b260325d070d8aa1dd2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 17:54:45 +0200 Subject: [PATCH 20/45] fixes --- goodmap/data_models/location.py | 7 ++++- goodmap/goodmap.py | 14 ++++++++-- tests/unit_tests/data_models/test_location.py | 28 ++++++++++++++++++- tests/unit_tests/test_core_api.py | 7 +++-- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/goodmap/data_models/location.py b/goodmap/data_models/location.py index ea8c4024..e5281525 100644 --- a/goodmap/data_models/location.py +++ b/goodmap/data_models/location.py @@ -6,6 +6,7 @@ """ import warnings +from collections.abc import Iterable from typing import Annotated, Any, ClassVar, Type, cast from annotated_types import Ge, Le @@ -234,6 +235,7 @@ def _build_field_definition(field_type_str: str, allowed_values: frozenset[str]) def create_location_model( obligatory_fields: list[tuple[str, str]] | list[tuple[str, Type[Any]]], categories: dict[str, list[str]], + marker_style_fields: Iterable[str] = (), ) -> Type[BaseModel]: """Dynamically create a Location model with additional required fields. @@ -245,6 +247,9 @@ def create_location_model( - String type name: "str", "list", "int", "float", "bool", "dict" - Python type object: str, list, int, etc. (deprecated) categories: Dict mapping field names to allowed values (enums). + marker_style_fields: Field names referenced by the deployment's marker_styles + config (icon_field/color_field) - the only ones whose values + need to ride along on basic_info() for pin styling. Returns: A Location model class extending LocationBase with additional fields @@ -275,5 +280,5 @@ def create_location_model( __module__="goodmap.data_models.location", **fields, ) - location_model.pin_marker_fields = frozenset(categories.keys()) & fields.keys() + location_model.pin_marker_fields = frozenset(marker_style_fields) & fields.keys() return location_model diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 56ee2173..ecb80ebb 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -131,8 +131,18 @@ def _setup_location_model( except (KeyError, AttributeError): categories = {} - if categories: - location_model = create_location_model(obligatory_fields, categories) + try: + marker_styles = extended_db.get_marker_styles() + except (KeyError, AttributeError): + marker_styles = {} + marker_style_fields = { + field + for field in (marker_styles.get("icon_field"), marker_styles.get("color_field")) + if field is not None + } + + if categories or marker_style_fields: + location_model = create_location_model(obligatory_fields, categories, marker_style_fields) extended_db = extend_db_with_goodmap_queries(extended_db, location_model) return obligatory_fields, categories, location_model, extended_db diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index a9cb1d5c..611b4765 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -131,11 +131,12 @@ def test_category_validation_rejects_invalid_list_item(): def test_basic_info_includes_category_field_values(): - """basic_info() should surface category field values (for pin icon/color + """basic_info() should surface marker_style_fields' values (for pin icon/color selection) alongside the existing uuid/position/has_remark.""" location_model = create_location_model( obligatory_fields=[("type_of_place", "str"), ("name", "str")], categories={"type_of_place": ["parcel_locker", "container"]}, + marker_style_fields={"type_of_place"}, ) location = location_model( uuid="1", name="test", type_of_place="parcel_locker", position=(50, 50) @@ -158,6 +159,31 @@ def test_basic_info_omits_category_fields_when_none_configured(): assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} +def test_basic_info_omits_category_fields_not_referenced_by_marker_style_fields(): + """A category not used by marker_styles.icon_field/color_field shouldn't ride + along on basic_info() just because it's a category - only marker_style_fields + controls what pin styling needs, not the full category set (a deployment can + have categories unrelated to marker display, e.g. used only for filtering).""" + location_model = create_location_model( + obligatory_fields=[("type_of_place", "str"), ("accessibility", "str")], + categories={ + "type_of_place": ["parcel_locker", "container"], + "accessibility": ["wheelchair", "none"], + }, + marker_style_fields={"type_of_place"}, + ) + location = location_model( + uuid="1", type_of_place="parcel_locker", accessibility="wheelchair", position=(50, 50) + ) + location = cast(LocationBase, location) + assert location.basic_info() == { + "uuid": "1", + "position": (50, 50), + "has_remark": False, + "type_of_place": "parcel_locker", + } + + def test_create_location_model_with_int_field(): """Test that non-str simple fields (like int) are created without max_length.""" location_model = create_location_model(obligatory_fields=[("capacity", "int")], categories={}) diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index 2a7ec185..39a54b92 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -316,13 +316,14 @@ def test_get_locations_accepts_valid_and_undeclared_parameters(test_app, query): def test_get_locations_includes_category_field_for_pin_styling(): - """/api/locations should surface category field values (e.g. a point-type - category), so the frontend can pick a marker icon/color without a full - per-location detail fetch.""" + """/api/locations should surface the field marker_styles.icon_field points at + (e.g. a point-type category), so the frontend can pick a marker icon/color + without a full per-location detail fetch.""" client = create_test_app( db_overrides={ "categories": {"point_type": ["parcel_locker", "container"]}, "location_obligatory_fields": [("point_type", "str"), ("name", "str")], + "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, "data": [ { "name": "locker-1", From e2a608a434646e995f00732069aa73a2dce69c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 22:24:01 +0200 Subject: [PATCH 21/45] fixes --- .../components/MarkerPopup/MarkerPopup.jsx | 11 +- frontend/src/services/http/endpoints.js | 8 ++ frontend/src/services/http/httpService.js | 27 ++++ .../tests/MarkerPopup/MarkerPopup.test.jsx | 69 ++++++++++ goodmap/api/api_models.py | 27 ++++ goodmap/api/core_api.py | 29 +++++ goodmap/data_models/location.py | 14 +- tests/unit_tests/data_models/test_location.py | 33 ++--- tests/unit_tests/test_core_api.py | 122 +++++++++++++++++- 9 files changed, 304 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index 9269c777..60ae7752 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -5,11 +5,13 @@ import { isMobile } from 'react-device-detect'; import { useTranslation } from 'react-i18next'; import httpService from '../../services/http/httpService'; import useMapStore from '../Map/store/map.store'; +import useMarkerStylesStore from '../Map/store/markerStyles.store'; import LocationDetailsBox from './LocationDetails'; import MobilePopup from './MobilePopup'; import DesktopPopup from './DesktopPopup'; import getTypedMarkerIcon from './getTypedMarkerIcon'; +import requestMarkerStyle from './requestMarkerStyle'; /** * Wrapper component that fetches full location details and renders them in a popup. @@ -81,6 +83,7 @@ LocationDetailsBoxWrapper.propTypes = { const MarkerPopup = ({ place }) => { const selectedLocationId = useMapStore(state => state.selectedLocationId); const setSelectedLocationId = useMapStore(state => state.setSelectedLocationId); + const lazyMarkerStyle = useMarkerStylesStore(state => state.stylesByUuid[place.uuid]); const [isClicked, setIsClicked] = useState(false); // TODO: this only opens the popup if `place`'s Marker is actually attached to @@ -102,14 +105,20 @@ const MarkerPopup = ({ place }) => { setIsClicked(true); }; + const handleMarkerVisible = () => { + requestMarkerStyle(place.uuid); + }; + const markerProps = { position: place.position, eventHandlers: { click: handleMarkerClick, + add: handleMarkerVisible, }, }; - const typedIcon = getTypedMarkerIcon(place); + const styledPlace = lazyMarkerStyle ? { ...place, ...lazyMarkerStyle } : place; + const typedIcon = getTypedMarkerIcon(styledPlace); if (typedIcon) { markerProps.icon = typedIcon; } diff --git a/frontend/src/services/http/endpoints.js b/frontend/src/services/http/endpoints.js index 282b60fa..6f53f501 100644 --- a/frontend/src/services/http/endpoints.js +++ b/frontend/src/services/http/endpoints.js @@ -28,6 +28,14 @@ export const LOCATIONS = '/api/locations'; */ export const LOCATIONS_CLUSTERED = '/api/locations-clustered'; +/** + * API endpoint for lazily fetching marker styling field values (whatever + * marker_styles.icon_field/color_field point at) for specific locations, by uuid. + * Used once a location's marker becomes individually visible, instead of upfront + * for every location - see lazy-load-marker-styling-plan.md. + */ +export const LOCATIONS_MARKER_STYLES = '/api/locations/marker-styles'; + /** * External API endpoint for address search (forward geocoding) using OpenStreetMap Nominatim. * Converts addresses/place names to geographic coordinates. diff --git a/frontend/src/services/http/httpService.js b/frontend/src/services/http/httpService.js index 01999f3c..0962b8a6 100644 --- a/frontend/src/services/http/httpService.js +++ b/frontend/src/services/http/httpService.js @@ -5,6 +5,7 @@ import { LOCATIONS, SEARCH_ADDRESS, LOCATIONS_CLUSTERED, + LOCATIONS_MARKER_STYLES, } from './endpoints'; import useMapStore from '../../components/Map/store/map.store'; @@ -207,6 +208,32 @@ const httpService = { } }, + /** + * Fetches marker styling field values for specific locations, by uuid. + * Used to lazily fetch pin icon/color data once a marker becomes individually + * visible, instead of upfront for every location. + * + * @param {string[]} uuids - Location UUIDs to fetch styling for + * @returns {Promise>} Promise resolving to a map of + * uuid -> styling field values; uuids with no styling are simply absent + */ + getMarkerStyles: async uuids => { + if (!uuids.length) { + return {}; + } + const params = new URLSearchParams(); + for (const uuid of uuids) { + params.append('uuid', uuid); + } + const response = await fetch(`${LOCATIONS_MARKER_STYLES}?${params.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + return jsonOrThrow(response, 'marker styles'); + }, + /** * Searches for addresses using OpenStreetMap Nominatim API. * Returns up to 5 results with geocoded coordinates. diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index 5beab11b..27807b94 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { MapContainer } from 'react-leaflet'; import MarkerPopup from '../../src/components/MarkerPopup/MarkerPopup'; import httpService from '../../src/services/http/httpService'; +import useMarkerStylesStore from '../../src/components/Map/store/markerStyles.store'; jest.mock('../../src/services/http/httpService'); @@ -153,3 +154,71 @@ describe('MarkerPopup with remark', () => { expect(style.height).toBe('50px'); }); }); + +describe('MarkerPopup lazy marker styling', () => { + beforeEach(() => { + jest.useFakeTimers(); + useMarkerStylesStore.setState({ stylesByUuid: {} }); + globalThis.MARKER_STYLES = { + icon_field: 'pointType', // eslint-disable-line camelcase -- matches backend API schema property name + icons: { parcelLocker: 'https://cdn.example.com/parcel-locker.svg' }, + }; + httpService.getMarkerStyles.mockResolvedValue({ + [location.uuid]: { pointType: 'parcelLocker' }, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + delete globalThis.MARKER_STYLES; + }); + + it('fetches marker styling once the marker becomes individually visible', async () => { + await act(async () => { + render( + + + , + ); + }); + + expect(httpService.getMarkerStyles).not.toHaveBeenCalled(); + + await act(async () => { + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + + expect(httpService.getMarkerStyles).toHaveBeenCalledWith([location.uuid]); + }); + + it('re-renders the marker with the lazily-fetched icon once it arrives', async () => { + await act(async () => { + render( + + + , + ); + }); + + // Nothing matched yet - default Leaflet icon, no custom pin + expect(document.querySelector('.custom-typed-marker-icon')).not.toBeInTheDocument(); + + await act(async () => { + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + + const marker = document.querySelector('.custom-typed-marker-icon'); + expect(marker).toBeInTheDocument(); + expect(marker.innerHTML).toContain('https://cdn.example.com/parcel-locker.svg'); + }); +}); diff --git a/goodmap/api/api_models.py b/goodmap/api/api_models.py index e59b7915..cbcb0c72 100644 --- a/goodmap/api/api_models.py +++ b/goodmap/api/api_models.py @@ -82,6 +82,33 @@ class LocationList(RootModel[list[LocationBasicInfo]]): """List of points, each with identity and position only.""" +class LocationMarkerStyles(RootModel[dict[str, dict[str, Any]]]): + """Map of uuid -> pin styling field values (whatever marker_styles.icon_field/ + color_field point at), for lazily fetching styling once a marker becomes + individually visible instead of getting it upfront for every location. + Unknown/missing uuids are simply absent from the response, not an error.""" + + +class MarkerStylesQueryParams(BaseModel): + """Query parameters of the marker styles lazy-loading endpoint.""" + + uuid: list[str] = Field(default_factory=list, description="Location UUIDs to fetch styling for") + + +def marker_style_values(location: BaseModel, style_fields: frozenset[str]) -> dict[str, Any]: + """Pin styling field values for `location`, as /api/locations/marker-styles returns them. + + This is API response shaping, not something the location domain model needs to + know how to do itself - it belongs alongside the models it fills, not on + LocationBase. + """ + return { + field: value + for field in sorted(style_fields) + if (value := getattr(location, field, None)) is not None + } + + class ClusterInfo(BaseModel): """One entry of the clustered list: either a single point or a cluster of them.""" diff --git a/goodmap/api/core_api.py b/goodmap/api/core_api.py index 09be7455..5423ed18 100644 --- a/goodmap/api/core_api.py +++ b/goodmap/api/core_api.py @@ -25,12 +25,15 @@ LanguagesResponse, LocationDetail, LocationList, + LocationMarkerStyles, LocationQueryParams, LocationReportRequest, LocationReportResponse, LocationSchemaResponse, + MarkerStylesQueryParams, SuccessResponse, VersionResponse, + marker_style_values, ) from goodmap.clustering import ( MAX_ZOOM, @@ -418,6 +421,32 @@ def get_location(location_id): formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data, shortcodes) return jsonify(formatted_data) + @core_api_blueprint.route("/locations/marker-styles", methods=["GET"]) + @spec.validate( + tags=[TAG_MAP_DATA], + query=MarkerStylesQueryParams, + resp=Response(HTTP_200=LocationMarkerStyles), + ) + def get_locations_marker_styles(): + """Get pin styling field values for specific locations, by uuid. + + For lazily fetching marker_styles-relevant field values only once a + client-side-clustered marker becomes individually visible, instead of + the frontend getting them upfront for every location (see + lazy-load-marker-styling-plan.md). Unknown or missing uuids are + silently omitted from the response rather than erroring the whole + request - a marker that's re-clustered mid-flight isn't a client bug. + """ + result: dict[str, dict[str, Any]] = {} + for location_uuid in request.args.getlist("uuid"): + location = database.get_location(location_uuid) + if location is None: + continue + styling = marker_style_values(location, location_model.pin_marker_fields) + if styling: + result[location_uuid] = styling + return jsonify(result) + @core_api_blueprint.route("/version", methods=["GET"]) @spec.validate(tags=[TAG_META], resp=Response(HTTP_200=VersionResponse)) def get_version(): diff --git a/goodmap/data_models/location.py b/goodmap/data_models/location.py index e5281525..6646bd8e 100644 --- a/goodmap/data_models/location.py +++ b/goodmap/data_models/location.py @@ -91,18 +91,16 @@ def model_dump(self, **kwargs) -> dict[str, Any]: return super().model_dump(**kwargs) def basic_info(self) -> dict[str, Any]: - """Get basic location information summary. + """Get basic location information summary: identity and position only. - Includes the uuid/position/remark flag always shown on the map, plus the - value of any category field named in ``pin_marker_fields`` - enough for the - frontend to choose a pin icon/color without fetching full location detail. + Includes the uuid/position/remark flag always shown on the map. Marker + styling field values are deliberately not here - see + ``goodmap.api.api_models.marker_style_values``, read separately and only + once a point is individually visible (not folded into a cluster), so + points that aren't don't pay for it. """ data = self.model_dump(include={"uuid", "position"}) data["has_remark"] = bool(self.remark) - for field in sorted(self.pin_marker_fields): - value = getattr(self, field, None) - if value is not None: - data[field] = value return data diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index 611b4765..9af9a3e2 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -1,5 +1,5 @@ import warnings -from typing import cast +from typing import Type, cast import pytest @@ -130,9 +130,10 @@ def test_category_validation_rejects_invalid_list_item(): location_model(uuid="2", tags=["red", "yellow"], position=(50, 50)) -def test_basic_info_includes_category_field_values(): - """basic_info() should surface marker_style_fields' values (for pin icon/color - selection) alongside the existing uuid/position/has_remark.""" +def test_basic_info_omits_marker_style_field_values(): + """basic_info() carries identity/position only - marker styling values are + fetched separately (see goodmap.api.api_models.marker_style_values and + lazy-load-marker-styling-plan.md), only once a marker is actually visible.""" location_model = create_location_model( obligatory_fields=[("type_of_place", "str"), ("name", "str")], categories={"type_of_place": ["parcel_locker", "container"]}, @@ -142,12 +143,7 @@ def test_basic_info_includes_category_field_values(): uuid="1", name="test", type_of_place="parcel_locker", position=(50, 50) ) location = cast(LocationBase, location) - assert location.basic_info() == { - "uuid": "1", - "position": (50, 50), - "has_remark": False, - "type_of_place": "parcel_locker", - } + assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} def test_basic_info_omits_category_fields_when_none_configured(): @@ -159,9 +155,9 @@ def test_basic_info_omits_category_fields_when_none_configured(): assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} -def test_basic_info_omits_category_fields_not_referenced_by_marker_style_fields(): - """A category not used by marker_styles.icon_field/color_field shouldn't ride - along on basic_info() just because it's a category - only marker_style_fields +def test_pin_marker_fields_omits_categories_not_referenced_by_marker_style_fields(): + """A category not used by marker_styles.icon_field/color_field shouldn't land + in pin_marker_fields just because it's a category - only marker_style_fields controls what pin styling needs, not the full category set (a deployment can have categories unrelated to marker display, e.g. used only for filtering).""" location_model = create_location_model( @@ -172,16 +168,9 @@ def test_basic_info_omits_category_fields_not_referenced_by_marker_style_fields( }, marker_style_fields={"type_of_place"}, ) - location = location_model( - uuid="1", type_of_place="parcel_locker", accessibility="wheelchair", position=(50, 50) + assert cast(Type[LocationBase], location_model).pin_marker_fields == frozenset( + {"type_of_place"} ) - location = cast(LocationBase, location) - assert location.basic_info() == { - "uuid": "1", - "position": (50, 50), - "has_remark": False, - "type_of_place": "parcel_locker", - } def test_create_location_model_with_int_field(): diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index 39a54b92..d0659c83 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -315,10 +315,11 @@ def test_get_locations_accepts_valid_and_undeclared_parameters(test_app, query): assert response.status_code == 200 -def test_get_locations_includes_category_field_for_pin_styling(): - """/api/locations should surface the field marker_styles.icon_field points at - (e.g. a point-type category), so the frontend can pick a marker icon/color - without a full per-location detail fetch.""" +def test_get_locations_omits_marker_style_field_values(): + """/api/locations should not surface the field marker_styles.icon_field + points at (e.g. a point-type category) - that value is fetched lazily via + /api/locations/marker-styles, only once a marker is individually visible, + instead of upfront for every location (see lazy-load-marker-styling-plan.md).""" client = create_test_app( db_overrides={ "categories": {"point_type": ["parcel_locker", "container"]}, @@ -344,11 +345,122 @@ def test_get_locations_includes_category_field_for_pin_styling(): "uuid": "11111111-1111-1111-1111-111111111111", "position": [50, 50], "has_remark": False, - "point_type": "parcel_locker", }, ] +def test_get_locations_marker_styles_returns_requested_uuids_styling(): + """The lazy marker-styles endpoint returns just the marker_styles-relevant + field values for the requested uuids, not the full location.""" + client = create_test_app( + db_overrides={ + "categories": {"point_type": ["parcel_locker", "container"]}, + "location_obligatory_fields": [("point_type", "str"), ("name", "str")], + "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, + "data": [ + { + "name": "locker-1", + "position": [50, 50], + "point_type": "parcel_locker", + "uuid": "11111111-1111-1111-1111-111111111111", + }, + { + "name": "locker-2", + "position": [51, 51], + "point_type": "container", + "uuid": "22222222-2222-2222-2222-222222222222", + }, + ], + "visible_data": ["name", "point_type"], + } + ) + + response = client.get("/api/locations/marker-styles?uuid=11111111-1111-1111-1111-111111111111") + + assert response.status_code == 200 + assert response.json == { + "11111111-1111-1111-1111-111111111111": {"point_type": "parcel_locker"}, + } + + +def test_get_locations_marker_styles_supports_multiple_uuids(): + client = create_test_app( + db_overrides={ + "categories": {"point_type": ["parcel_locker", "container"]}, + "location_obligatory_fields": [("point_type", "str"), ("name", "str")], + "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, + "data": [ + { + "name": "locker-1", + "position": [50, 50], + "point_type": "parcel_locker", + "uuid": "11111111-1111-1111-1111-111111111111", + }, + { + "name": "locker-2", + "position": [51, 51], + "point_type": "container", + "uuid": "22222222-2222-2222-2222-222222222222", + }, + ], + "visible_data": ["name", "point_type"], + } + ) + + response = client.get( + "/api/locations/marker-styles" + "?uuid=11111111-1111-1111-1111-111111111111" + "&uuid=22222222-2222-2222-2222-222222222222" + ) + + assert response.status_code == 200 + assert response.json == { + "11111111-1111-1111-1111-111111111111": {"point_type": "parcel_locker"}, + "22222222-2222-2222-2222-222222222222": {"point_type": "container"}, + } + + +def test_get_locations_marker_styles_omits_unknown_uuids(): + """An unknown/re-clustered-away uuid doesn't error the whole request - it's + just absent from the response.""" + client = create_test_app( + db_overrides={ + "categories": {"point_type": ["parcel_locker"]}, + "location_obligatory_fields": [("point_type", "str"), ("name", "str")], + "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, + "data": [ + { + "name": "locker-1", + "position": [50, 50], + "point_type": "parcel_locker", + "uuid": "11111111-1111-1111-1111-111111111111", + }, + ], + "visible_data": ["name", "point_type"], + } + ) + + response = client.get( + "/api/locations/marker-styles" + "?uuid=11111111-1111-1111-1111-111111111111" + "&uuid=99999999-9999-9999-9999-999999999999" + ) + + assert response.status_code == 200 + assert response.json == { + "11111111-1111-1111-1111-111111111111": {"point_type": "parcel_locker"}, + } + + +def test_get_locations_marker_styles_empty_query_returns_empty_object(): + client = create_test_app(db_overrides={"categories": {}}) + + response = client.get("/api/locations/marker-styles") + + assert response.status_code == 200 + assert response.json == {} + + def test_get_locations_multi_value_same_category_uses_or_semantics(): """Selecting several checkboxes within one category should return the union of matches, not only entries that have every selected value.""" From 34a1f8420ba0bc554dd104d06d22da53f9cdbbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 22:44:26 +0200 Subject: [PATCH 22/45] fixes --- goodmap/api/core_api.py | 3 ++- goodmap/data_models/location.py | 16 ++---------- goodmap/goodmap.py | 21 +++++++++++---- tests/unit_tests/data_models/test_location.py | 26 +++---------------- 4 files changed, 24 insertions(+), 42 deletions(-) diff --git a/goodmap/api/core_api.py b/goodmap/api/core_api.py index 5423ed18..4ff501b9 100644 --- a/goodmap/api/core_api.py +++ b/goodmap/api/core_api.py @@ -198,6 +198,7 @@ def core_pages( photo_attachment_config: AttachmentConfig, feature_flags: FeatureFlagSet, shortcodes: dict[str, Shortcode], + pin_marker_fields: frozenset[str] = frozenset(), ) -> Blueprint: core_api_blueprint = Blueprint("api", __name__, url_prefix="/api") @@ -442,7 +443,7 @@ def get_locations_marker_styles(): location = database.get_location(location_uuid) if location is None: continue - styling = marker_style_values(location, location_model.pin_marker_fields) + styling = marker_style_values(location, pin_marker_fields) if styling: result[location_uuid] = styling return jsonify(result) diff --git a/goodmap/data_models/location.py b/goodmap/data_models/location.py index 6646bd8e..6fa5f934 100644 --- a/goodmap/data_models/location.py +++ b/goodmap/data_models/location.py @@ -6,8 +6,7 @@ """ import warnings -from collections.abc import Iterable -from typing import Annotated, Any, ClassVar, Type, cast +from typing import Annotated, Any, Type, cast from annotated_types import Ge, Le from pydantic import ( @@ -38,11 +37,6 @@ class LocationBase(BaseModel, extra="allow"): uuid: str = Field(..., max_length=100) # TODO make this UUID and deprecate string remark: str | None = None - # Names of category fields whose values should ride along on basic_info(), - # e.g. so the frontend can pick a pin icon/color without a full detail fetch. - # Populated by create_location_model(); empty for the base class. - pin_marker_fields: ClassVar[frozenset[str]] = frozenset() - @model_validator(mode="before") @classmethod def validate_uuid_exists(cls, data: Any) -> Any: @@ -233,7 +227,6 @@ def _build_field_definition(field_type_str: str, allowed_values: frozenset[str]) def create_location_model( obligatory_fields: list[tuple[str, str]] | list[tuple[str, Type[Any]]], categories: dict[str, list[str]], - marker_style_fields: Iterable[str] = (), ) -> Type[BaseModel]: """Dynamically create a Location model with additional required fields. @@ -245,9 +238,6 @@ def create_location_model( - String type name: "str", "list", "int", "float", "bool", "dict" - Python type object: str, list, int, etc. (deprecated) categories: Dict mapping field names to allowed values (enums). - marker_style_fields: Field names referenced by the deployment's marker_styles - config (icon_field/color_field) - the only ones whose values - need to ride along on basic_info() for pin styling. Returns: A Location model class extending LocationBase with additional fields @@ -272,11 +262,9 @@ def create_location_model( allowed = frozenset() fields[field_name] = _build_field_definition(field_type_str, allowed) - location_model = create_model( + return create_model( "Location", __base__=LocationBase, __module__="goodmap.data_models.location", **fields, ) - location_model.pin_marker_fields = frozenset(marker_style_fields) & fields.keys() - return location_model diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index ecb80ebb..ff7fe89e 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -112,14 +112,18 @@ def _add_cors(response): def _setup_location_model( db: Any, -) -> tuple[list[Any], dict[str, Any], type[BaseModel], Any]: +) -> tuple[list[Any], dict[str, Any], type[BaseModel], Any, frozenset[str]]: """Configure location model and db with lazy-loading and categories support. Args: db: The database instance to extend with location queries. Returns: - Tuple of (obligatory_fields, categories, location_model, db). + Tuple of (obligatory_fields, categories, location_model, db, pin_marker_fields). + pin_marker_fields is app-wiring knowledge - which of this deployment's fields + the marker_styles config (icon_field/color_field) actually points at - not + something the location model itself needs to know; it's threaded to core_pages() + for goodmap.api.api_models.marker_style_values() to use. """ obligatory_fields = get_location_obligatory_fields(db) location_model = create_location_model(obligatory_fields, {}) @@ -142,10 +146,13 @@ def _setup_location_model( } if categories or marker_style_fields: - location_model = create_location_model(obligatory_fields, categories, marker_style_fields) + location_model = create_location_model(obligatory_fields, categories) extended_db = extend_db_with_goodmap_queries(extended_db, location_model) - return obligatory_fields, categories, location_model, extended_db + field_names = {name for name, _ in obligatory_fields} + pin_marker_fields = frozenset(marker_style_fields) & field_names + + return obligatory_fields, categories, location_model, extended_db, pin_marker_fields def create_app(config_path: str) -> platzky.Engine: @@ -206,11 +213,14 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: app.config["MAX_CONTENT_LENGTH"] = config.attachment.max_size + MULTIPART_OVERHEAD_ALLOWANCE if app.is_enabled(UseLazyLoading): - location_obligatory_fields, _, location_model, app.db = _setup_location_model(app.db) + location_obligatory_fields, _, location_model, app.db, pin_marker_fields = ( + _setup_location_model(app.db) + ) else: location_obligatory_fields = [] location_model = create_location_model([], {}) app.db = extend_db_with_goodmap_queries(app.db, location_model) + pin_marker_fields = frozenset() app.extensions["goodmap"] = {"location_obligatory_fields": location_obligatory_fields} @@ -278,6 +288,7 @@ def handle_csrf_error(error): photo_attachment_config=photo_attachment_config, feature_flags=config.feature_flags, shortcodes=shortcodes, + pin_marker_fields=pin_marker_fields, ) app.register_blueprint(cp) diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index 9af9a3e2..60ce982a 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -1,5 +1,5 @@ import warnings -from typing import Type, cast +from typing import cast import pytest @@ -130,14 +130,14 @@ def test_category_validation_rejects_invalid_list_item(): location_model(uuid="2", tags=["red", "yellow"], position=(50, 50)) -def test_basic_info_omits_marker_style_field_values(): - """basic_info() carries identity/position only - marker styling values are +def test_basic_info_omits_category_field_values(): + """basic_info() carries identity/position only, even for a category field a + deployment's marker_styles config might reference - marker styling values are fetched separately (see goodmap.api.api_models.marker_style_values and lazy-load-marker-styling-plan.md), only once a marker is actually visible.""" location_model = create_location_model( obligatory_fields=[("type_of_place", "str"), ("name", "str")], categories={"type_of_place": ["parcel_locker", "container"]}, - marker_style_fields={"type_of_place"}, ) location = location_model( uuid="1", name="test", type_of_place="parcel_locker", position=(50, 50) @@ -155,24 +155,6 @@ def test_basic_info_omits_category_fields_when_none_configured(): assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} -def test_pin_marker_fields_omits_categories_not_referenced_by_marker_style_fields(): - """A category not used by marker_styles.icon_field/color_field shouldn't land - in pin_marker_fields just because it's a category - only marker_style_fields - controls what pin styling needs, not the full category set (a deployment can - have categories unrelated to marker display, e.g. used only for filtering).""" - location_model = create_location_model( - obligatory_fields=[("type_of_place", "str"), ("accessibility", "str")], - categories={ - "type_of_place": ["parcel_locker", "container"], - "accessibility": ["wheelchair", "none"], - }, - marker_style_fields={"type_of_place"}, - ) - assert cast(Type[LocationBase], location_model).pin_marker_fields == frozenset( - {"type_of_place"} - ) - - def test_create_location_model_with_int_field(): """Test that non-str simple fields (like int) are created without max_length.""" location_model = create_location_model(obligatory_fields=[("capacity", "int")], categories={}) From 41cfcac8658f76895d20d1f4a928b5e4996bbd7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 23:11:06 +0200 Subject: [PATCH 23/45] fix --- .../MarkerPopup/getTypedMarkerIcon.jsx | 5 +- .../tests/MarkerPopup/MarkerPopup.test.jsx | 46 +++++++++++++---- goodmap/api/api_models.py | 32 ++++++------ goodmap/api/core_api.py | 17 +++---- goodmap/data_models/location.py | 9 ++-- tests/unit_tests/data_models/test_location.py | 26 ++++------ tests/unit_tests/test_core_api.py | 50 ++++++++++++++++--- 7 files changed, 123 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 96b04b3a..cdbc1dc1 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -89,7 +89,10 @@ PinIcon.propTypes = { * matched, or `null` (falls back to Leaflet's default marker) when there's * neither a match nor a remark to show. * - * @param {Object} place - Location data, as returned by GET /api/locations + * @param {Object} place - Location data from GET /api/locations, merged with any + * styling lazily fetched for it from GET /api/locations/marker-styles (has_remark + * and marker_styles field values aren't in the initial /api/locations response - + * see lazy-load-marker-styling-plan.md) * @returns {import('leaflet').DivIcon|null} */ const getTypedMarkerIcon = place => { diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index 27807b94..35491106 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -36,6 +36,23 @@ const locationData = { }; httpService.getLocation.mockResolvedValue(locationData); +// Every mount now fires a lazy marker-styles request (see requestMarkerStyle.js) - +// a harmless default so it always resolves, even in tests that don't care about it. +httpService.getMarkerStyles.mockResolvedValue({}); + +/** + * requestMarkerStyle.js debounces/batches uuids through module-level state shared + * by every test in this file. Describes below that render with real timers must + * drain that debounce window before finishing, or its still-pending timer fires + * during a later (fake-timer) describe and merges its uuid into that batch. + */ +const flushMarkerStyleDebounce = () => + act( + () => + new Promise(resolve => { + setTimeout(resolve, 200); + }), + ); describe('MarkerPopup', () => { beforeEach(() => { @@ -55,8 +72,9 @@ describe('MarkerPopup', () => { ); }); - afterEach(() => { + afterEach(async () => { globalThis.fetch.mockRestore(); + await flushMarkerStyleDebounce(); }); it('should render marker without popup', () => { @@ -102,8 +120,9 @@ describe('MarkerPopup with remark', () => { }); }); - afterEach(() => { + afterEach(async () => { globalThis.fetch.mockRestore(); + await flushMarkerStyleDebounce(); }); it('should render our own pin with an asterisk badge when remark is true', () => { @@ -156,6 +175,15 @@ describe('MarkerPopup with remark', () => { }); describe('MarkerPopup lazy marker styling', () => { + // A uuid distinct from `location`'s (used by the describes above, which run with + // real timers) so a leftover real setTimeout from those can't resolve into this + // describe's store state mid-test and make "already known" skip our own request. + const lazyLocation = { + position: [51.2, 17.1], + uuid: 'lazy-marker-styling-uuid', + has_remark: false, // eslint-disable-line camelcase -- matches backend API schema property name + }; + beforeEach(() => { jest.useFakeTimers(); useMarkerStylesStore.setState({ stylesByUuid: {} }); @@ -164,7 +192,7 @@ describe('MarkerPopup lazy marker styling', () => { icons: { parcelLocker: 'https://cdn.example.com/parcel-locker.svg' }, }; httpService.getMarkerStyles.mockResolvedValue({ - [location.uuid]: { pointType: 'parcelLocker' }, + [lazyLocation.uuid]: { pointType: 'parcelLocker' }, }); }); @@ -177,34 +205,34 @@ describe('MarkerPopup lazy marker styling', () => { await act(async () => { render( - + , ); }); - expect(httpService.getMarkerStyles).not.toHaveBeenCalled(); + expect(httpService.getMarkerStyles).not.toHaveBeenCalledWith([lazyLocation.uuid]); await act(async () => { jest.advanceTimersByTime(200); await Promise.resolve(); }); - expect(httpService.getMarkerStyles).toHaveBeenCalledWith([location.uuid]); + expect(httpService.getMarkerStyles).toHaveBeenCalledWith([lazyLocation.uuid]); }); it('re-renders the marker with the lazily-fetched icon once it arrives', async () => { await act(async () => { render( - + , ); }); diff --git a/goodmap/api/api_models.py b/goodmap/api/api_models.py index cbcb0c72..eb5806ca 100644 --- a/goodmap/api/api_models.py +++ b/goodmap/api/api_models.py @@ -73,9 +73,6 @@ class LocationBasicInfo(BaseModel): uuid: str = Field(..., description="Location UUID") position: tuple[Latitude, Longitude] = Field(..., description=_POSITION_DESCRIPTION) - has_remark: bool = Field( - ..., description="Whether the point has a remark, not the remark itself" - ) class LocationList(RootModel[list[LocationBasicInfo]]): @@ -83,10 +80,11 @@ class LocationList(RootModel[list[LocationBasicInfo]]): class LocationMarkerStyles(RootModel[dict[str, dict[str, Any]]]): - """Map of uuid -> pin styling field values (whatever marker_styles.icon_field/ - color_field point at), for lazily fetching styling once a marker becomes - individually visible instead of getting it upfront for every location. - Unknown/missing uuids are simply absent from the response, not an error.""" + """Map of uuid -> pin styling data: has_remark (drives the asterisk badge) plus + whatever marker_styles.icon_field/color_field point at (drive icon/color) - for + lazily fetching it once a marker becomes individually visible instead of getting + it upfront for every location. Unknown/missing uuids are simply absent from the + response, not an error.""" class MarkerStylesQueryParams(BaseModel): @@ -96,17 +94,19 @@ class MarkerStylesQueryParams(BaseModel): def marker_style_values(location: BaseModel, style_fields: frozenset[str]) -> dict[str, Any]: - """Pin styling field values for `location`, as /api/locations/marker-styles returns them. + """Pin styling data for `location`, as /api/locations/marker-styles returns it. - This is API response shaping, not something the location domain model needs to - know how to do itself - it belongs alongside the models it fills, not on - LocationBase. + Always includes has_remark (drives the asterisk badge), plus the value of any + of `style_fields` this location actually has (drive icon/color). This is API + response shaping, not something the location domain model needs to know how to + do itself - it belongs alongside the models it fills, not on LocationBase. """ - return { - field: value - for field in sorted(style_fields) - if (value := getattr(location, field, None)) is not None - } + data: dict[str, Any] = {"has_remark": bool(getattr(location, "remark", None))} + for field in sorted(style_fields): + value = getattr(location, field, None) + if value is not None: + data[field] = value + return data class ClusterInfo(BaseModel): diff --git a/goodmap/api/core_api.py b/goodmap/api/core_api.py index 4ff501b9..ac4285c1 100644 --- a/goodmap/api/core_api.py +++ b/goodmap/api/core_api.py @@ -345,8 +345,9 @@ def report_location(): def get_locations(): """Get list of locations with basic info. - Returns locations filtered by query parameters, - showing only uuid, position, and whether each has a remark. + Returns locations filtered by query parameters, showing only uuid and + position. Pin styling (has_remark, marker_styles field values) is fetched + separately, per-uuid, via /api/locations/marker-styles. """ locations = get_locations_from_request(database, request.args) return jsonify(locations) @@ -429,11 +430,11 @@ def get_location(location_id): resp=Response(HTTP_200=LocationMarkerStyles), ) def get_locations_marker_styles(): - """Get pin styling field values for specific locations, by uuid. + """Get pin styling data for specific locations, by uuid. - For lazily fetching marker_styles-relevant field values only once a - client-side-clustered marker becomes individually visible, instead of - the frontend getting them upfront for every location (see + For lazily fetching has_remark and marker_styles-relevant field values + only once a client-side-clustered marker becomes individually visible, + instead of the frontend getting them upfront for every location (see lazy-load-marker-styling-plan.md). Unknown or missing uuids are silently omitted from the response rather than erroring the whole request - a marker that's re-clustered mid-flight isn't a client bug. @@ -443,9 +444,7 @@ def get_locations_marker_styles(): location = database.get_location(location_uuid) if location is None: continue - styling = marker_style_values(location, pin_marker_fields) - if styling: - result[location_uuid] = styling + result[location_uuid] = marker_style_values(location, pin_marker_fields) return jsonify(result) @core_api_blueprint.route("/version", methods=["GET"]) diff --git a/goodmap/data_models/location.py b/goodmap/data_models/location.py index 6fa5f934..98d5b412 100644 --- a/goodmap/data_models/location.py +++ b/goodmap/data_models/location.py @@ -87,15 +87,14 @@ def model_dump(self, **kwargs) -> dict[str, Any]: def basic_info(self) -> dict[str, Any]: """Get basic location information summary: identity and position only. - Includes the uuid/position/remark flag always shown on the map. Marker - styling field values are deliberately not here - see + Everything about how this point's marker should look - whether it has a + remark (drives the asterisk badge), any marker_styles field values (drive + icon/color) - is deliberately not here; see ``goodmap.api.api_models.marker_style_values``, read separately and only once a point is individually visible (not folded into a cluster), so points that aren't don't pay for it. """ - data = self.model_dump(include={"uuid", "position"}) - data["has_remark"] = bool(self.remark) - return data + return self.model_dump(include={"uuid", "position"}) _TYPE_MAPPING: dict[str, type] = { diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index 60ce982a..adbbf8db 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -130,29 +130,25 @@ def test_category_validation_rejects_invalid_list_item(): location_model(uuid="2", tags=["red", "yellow"], position=(50, 50)) -def test_basic_info_omits_category_field_values(): - """basic_info() carries identity/position only, even for a category field a - deployment's marker_styles config might reference - marker styling values are - fetched separately (see goodmap.api.api_models.marker_style_values and +def test_basic_info_is_identity_and_position_only(): + """basic_info() carries uuid/position only, even for a category field a + deployment's marker_styles config might reference and even when the location + has a remark - both has_remark and marker styling values are fetched + separately (see goodmap.api.api_models.marker_style_values and lazy-load-marker-styling-plan.md), only once a marker is actually visible.""" location_model = create_location_model( obligatory_fields=[("type_of_place", "str"), ("name", "str")], categories={"type_of_place": ["parcel_locker", "container"]}, ) location = location_model( - uuid="1", name="test", type_of_place="parcel_locker", position=(50, 50) + uuid="1", + name="test", + type_of_place="parcel_locker", + position=(50, 50), + remark="a remark", ) location = cast(LocationBase, location) - assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} - - -def test_basic_info_omits_category_fields_when_none_configured(): - """Backward compatibility: deployments without categories get the original - uuid/position/has_remark shape, unchanged.""" - location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) - location = location_model(uuid="1", name="test", position=(50, 50)) - location = cast(LocationBase, location) - assert location.basic_info() == {"uuid": "1", "position": (50, 50), "has_remark": False} + assert location.basic_info() == {"uuid": "1", "position": (50, 50)} def test_create_location_model_with_int_field(): diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index d0659c83..4185348d 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -272,12 +272,10 @@ def test_get_locations(test_app): { "uuid": "11111111-1111-1111-1111-111111111111", "position": [50, 50], - "has_remark": True, }, { "uuid": "22222222-2222-2222-2222-222222222222", "position": [60, 60], - "has_remark": False, }, ] @@ -344,7 +342,6 @@ def test_get_locations_omits_marker_style_field_values(): { "uuid": "11111111-1111-1111-1111-111111111111", "position": [50, 50], - "has_remark": False, }, ] @@ -379,7 +376,10 @@ def test_get_locations_marker_styles_returns_requested_uuids_styling(): assert response.status_code == 200 assert response.json == { - "11111111-1111-1111-1111-111111111111": {"point_type": "parcel_locker"}, + "11111111-1111-1111-1111-111111111111": { + "has_remark": False, + "point_type": "parcel_locker", + }, } @@ -415,8 +415,14 @@ def test_get_locations_marker_styles_supports_multiple_uuids(): assert response.status_code == 200 assert response.json == { - "11111111-1111-1111-1111-111111111111": {"point_type": "parcel_locker"}, - "22222222-2222-2222-2222-222222222222": {"point_type": "container"}, + "11111111-1111-1111-1111-111111111111": { + "has_remark": False, + "point_type": "parcel_locker", + }, + "22222222-2222-2222-2222-222222222222": { + "has_remark": False, + "point_type": "container", + }, } @@ -448,7 +454,37 @@ def test_get_locations_marker_styles_omits_unknown_uuids(): assert response.status_code == 200 assert response.json == { - "11111111-1111-1111-1111-111111111111": {"point_type": "parcel_locker"}, + "11111111-1111-1111-1111-111111111111": { + "has_remark": False, + "point_type": "parcel_locker", + }, + } + + +def test_get_locations_marker_styles_includes_has_remark_without_marker_styles_config(): + """has_remark drives the asterisk badge independently of marker_styles - + deployments with no icon_field/color_field configured still need it fetched + lazily, the same as everyone else.""" + client = create_test_app( + db_overrides={ + "categories": {}, + "location_obligatory_fields": [("name", "str")], + "data": [ + { + "name": "test", + "position": [50, 50], + "uuid": "11111111-1111-1111-1111-111111111111", + "remark": "this is a remark", + }, + ], + } + ) + + response = client.get("/api/locations/marker-styles?uuid=11111111-1111-1111-1111-111111111111") + + assert response.status_code == 200 + assert response.json == { + "11111111-1111-1111-1111-111111111111": {"has_remark": True}, } From 309a79df9cd00705068d7d469bd4f47f410df25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 23:14:11 +0200 Subject: [PATCH 24/45] added missing files --- .../Map/store/markerStyles.store.js | 14 ++++ .../MarkerPopup/requestMarkerStyle.js | 54 +++++++++++++++ .../MarkerPopup/requestMarkerStyle.test.js | 66 +++++++++++++++++++ tests/unit_tests/test_api_models.py | 43 ++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 frontend/src/components/Map/store/markerStyles.store.js create mode 100644 frontend/src/components/MarkerPopup/requestMarkerStyle.js create mode 100644 frontend/tests/MarkerPopup/requestMarkerStyle.test.js create mode 100644 tests/unit_tests/test_api_models.py diff --git a/frontend/src/components/Map/store/markerStyles.store.js b/frontend/src/components/Map/store/markerStyles.store.js new file mode 100644 index 00000000..8608056a --- /dev/null +++ b/frontend/src/components/Map/store/markerStyles.store.js @@ -0,0 +1,14 @@ +import { create } from 'zustand'; + +/** + * uuid -> resolved marker-styling field values (whatever marker_styles.icon_field/ + * color_field point at), lazily fetched once a client-side-clustered marker becomes + * individually visible - see lazy-load-marker-styling-plan.md. A uuid with no + * matching styling is still recorded, as {}, so it isn't re-requested forever. + */ +const useMarkerStylesStore = create(set => ({ + stylesByUuid: {}, + mergeStyles: styles => set(state => ({ stylesByUuid: { ...state.stylesByUuid, ...styles } })), +})); + +export default useMarkerStylesStore; diff --git a/frontend/src/components/MarkerPopup/requestMarkerStyle.js b/frontend/src/components/MarkerPopup/requestMarkerStyle.js new file mode 100644 index 00000000..59d6029d --- /dev/null +++ b/frontend/src/components/MarkerPopup/requestMarkerStyle.js @@ -0,0 +1,54 @@ +import httpService from '../../services/http/httpService'; +import useMarkerStylesStore from '../Map/store/markerStyles.store'; + +const BATCH_DEBOUNCE_MS = 150; + +let pendingUuids = new Set(); +let timer = null; + +/** + * Queues `uuid` for a batched GET /api/locations/marker-styles fetch, once its + * marker becomes individually visible (not folded into a cluster) - see + * lazy-load-marker-styling-plan.md. Fetches pin styling data (has_remark plus any + * marker_styles field values), so it's needed regardless of whether marker_styles + * is even configured - has_remark alone still drives the asterisk badge. Debounced + * so that markers becoming visible in quick succession (panning, zooming, a + * cluster spiderfying) share one request instead of firing one per marker. + * + * Scoped to client-side clustering for now - server-side clustering's own + * lazy-loading trigger is a separate follow-up (see the plan doc). + * + * @param {string} uuid - Location UUID whose marker just became individually visible + */ +const requestMarkerStyle = uuid => { + if (globalThis.FEATURE_FLAGS?.USE_SERVER_SIDE_CLUSTERING) { + return; + } + + const alreadyKnown = uuid in useMarkerStylesStore.getState().stylesByUuid; + if (alreadyKnown || pendingUuids.has(uuid)) { + return; + } + pendingUuids.add(uuid); + + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + const uuids = [...pendingUuids]; + pendingUuids = new Set(); + timer = null; + + httpService + .getMarkerStyles(uuids) + .then(styles => { + // Every requested uuid is recorded, even with no matching styling + // ({}), so it isn't queued again on the next re-cluster. + const withDefaults = Object.fromEntries(uuids.map(u => [u, styles[u] ?? {}])); + useMarkerStylesStore.getState().mergeStyles(withDefaults); + }) + .catch(error => console.error('Failed to fetch marker styles:', error)); + }, BATCH_DEBOUNCE_MS); +}; + +export default requestMarkerStyle; diff --git a/frontend/tests/MarkerPopup/requestMarkerStyle.test.js b/frontend/tests/MarkerPopup/requestMarkerStyle.test.js new file mode 100644 index 00000000..7f2abebf --- /dev/null +++ b/frontend/tests/MarkerPopup/requestMarkerStyle.test.js @@ -0,0 +1,66 @@ +import requestMarkerStyle from '../../src/components/MarkerPopup/requestMarkerStyle'; +import httpService from '../../src/services/http/httpService'; +import useMarkerStylesStore from '../../src/components/Map/store/markerStyles.store'; + +jest.mock('../../src/services/http/httpService'); + +describe('requestMarkerStyle', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + useMarkerStylesStore.setState({ stylesByUuid: {} }); + globalThis.MARKER_STYLES = { icon_field: 'pointType' }; // eslint-disable-line camelcase -- matches backend API schema property name + delete globalThis.FEATURE_FLAGS; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + delete globalThis.MARKER_STYLES; + delete globalThis.FEATURE_FLAGS; + }); + + it('still fetches when marker styling is not configured, for has_remark', () => { + globalThis.MARKER_STYLES = {}; + httpService.getMarkerStyles.mockResolvedValue({ 'uuid-1': { has_remark: true } }); // eslint-disable-line camelcase -- matches backend API schema property name + requestMarkerStyle('uuid-1'); + jest.runAllTimers(); + expect(httpService.getMarkerStyles).toHaveBeenCalledWith(['uuid-1']); + }); + + it('does nothing when server-side clustering is enabled', () => { + globalThis.FEATURE_FLAGS = { USE_SERVER_SIDE_CLUSTERING: true }; + requestMarkerStyle('uuid-1'); + jest.runAllTimers(); + expect(httpService.getMarkerStyles).not.toHaveBeenCalled(); + }); + + it('batches uuids requested within the debounce window into one request', async () => { + httpService.getMarkerStyles.mockResolvedValue({ 'uuid-1': { pointType: 'a' } }); + requestMarkerStyle('uuid-1'); + requestMarkerStyle('uuid-2'); + jest.runAllTimers(); + await Promise.resolve(); + expect(httpService.getMarkerStyles).toHaveBeenCalledTimes(1); + expect(httpService.getMarkerStyles).toHaveBeenCalledWith(['uuid-1', 'uuid-2']); + }); + + it('merges results into the store, defaulting unmatched uuids to {}', async () => { + httpService.getMarkerStyles.mockResolvedValue({ 'uuid-1': { pointType: 'a' } }); + requestMarkerStyle('uuid-1'); + requestMarkerStyle('uuid-2'); + jest.runAllTimers(); + await Promise.resolve(); + expect(useMarkerStylesStore.getState().stylesByUuid).toEqual({ + 'uuid-1': { pointType: 'a' }, + 'uuid-2': {}, + }); + }); + + it('does not re-request a uuid already known, even with no matching styling', () => { + useMarkerStylesStore.setState({ stylesByUuid: { 'uuid-1': {} } }); + requestMarkerStyle('uuid-1'); + jest.runAllTimers(); + expect(httpService.getMarkerStyles).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit_tests/test_api_models.py b/tests/unit_tests/test_api_models.py new file mode 100644 index 00000000..2f5af344 --- /dev/null +++ b/tests/unit_tests/test_api_models.py @@ -0,0 +1,43 @@ +from typing import cast + +from goodmap.api.api_models import marker_style_values +from goodmap.data_models.location import LocationBase, create_location_model + + +def test_marker_style_values_includes_has_remark_and_configured_field_values(): + """marker_style_values() always includes has_remark (drives the asterisk + badge), plus the requested style_fields' values (drive icon/color) off the + given location.""" + location_model = create_location_model( + obligatory_fields=[("type_of_place", "str"), ("name", "str")], + categories={"type_of_place": ["parcel_locker", "container"]}, + ) + location = location_model( + uuid="1", + name="test", + type_of_place="parcel_locker", + position=(50, 50), + remark="a remark", + ) + location = cast(LocationBase, location) + assert marker_style_values(location, frozenset({"type_of_place"})) == { + "has_remark": True, + "type_of_place": "parcel_locker", + } + + +def test_marker_style_values_has_remark_false_and_empty_when_no_style_fields(): + location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) + location = location_model(uuid="1", name="test", position=(50, 50)) + location = cast(LocationBase, location) + assert marker_style_values(location, frozenset()) == {"has_remark": False} + + +def test_marker_style_values_ignores_style_fields_the_location_does_not_have(): + """A style field that isn't actually one of this location's attributes (e.g. + misconfigured marker_styles, or narrowed away upstream) is simply skipped, + not an error.""" + location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) + location = location_model(uuid="1", name="test", position=(50, 50)) + location = cast(LocationBase, location) + assert marker_style_values(location, frozenset({"nonexistent_field"})) == {"has_remark": False} From 0063c97a75256db3979fe3932d46061947d26431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 23:17:24 +0200 Subject: [PATCH 25/45] fix --- goodmap/data_models/location.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/goodmap/data_models/location.py b/goodmap/data_models/location.py index 98d5b412..8993eca3 100644 --- a/goodmap/data_models/location.py +++ b/goodmap/data_models/location.py @@ -86,13 +86,6 @@ def model_dump(self, **kwargs) -> dict[str, Any]: def basic_info(self) -> dict[str, Any]: """Get basic location information summary: identity and position only. - - Everything about how this point's marker should look - whether it has a - remark (drives the asterisk badge), any marker_styles field values (drive - icon/color) - is deliberately not here; see - ``goodmap.api.api_models.marker_style_values``, read separately and only - once a point is individually visible (not folded into a cluster), so - points that aren't don't pay for it. """ return self.model_dump(include={"uuid", "position"}) From fe0fb270789844448a930e4e428e405d3c7a1845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 23:23:13 +0200 Subject: [PATCH 26/45] refactor --- .../tests/MarkerPopup/MarkerPopup.test.jsx | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index 35491106..79f2dfa2 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -201,18 +201,22 @@ describe('MarkerPopup lazy marker styling', () => { delete globalThis.MARKER_STYLES; }); + // render() already wraps itself in act(), so there's nothing left for a caller + // to flush - wrapping it again is redundant (and duplicated across the two + // tests below, which is what this helper avoids). + const renderLazyLocationMarker = () => + render( + + + , + ); + it('fetches marker styling once the marker becomes individually visible', async () => { - await act(async () => { - render( - - - , - ); - }); + renderLazyLocationMarker(); expect(httpService.getMarkerStyles).not.toHaveBeenCalledWith([lazyLocation.uuid]); @@ -225,17 +229,7 @@ describe('MarkerPopup lazy marker styling', () => { }); it('re-renders the marker with the lazily-fetched icon once it arrives', async () => { - await act(async () => { - render( - - - , - ); - }); + renderLazyLocationMarker(); // Nothing matched yet - default Leaflet icon, no custom pin expect(document.querySelector('.custom-typed-marker-icon')).not.toBeInTheDocument(); From 50fad555e728812c16094022b13055d54fc57ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 20 Aug 2026 23:36:12 +0200 Subject: [PATCH 27/45] refactor --- goodmap/data_models/location.py | 3 +- tests/unit_tests/test_core_api.py | 112 ++++++++++-------------------- 2 files changed, 38 insertions(+), 77 deletions(-) diff --git a/goodmap/data_models/location.py b/goodmap/data_models/location.py index 8993eca3..8b363cdd 100644 --- a/goodmap/data_models/location.py +++ b/goodmap/data_models/location.py @@ -85,8 +85,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]: return super().model_dump(**kwargs) def basic_info(self) -> dict[str, Any]: - """Get basic location information summary: identity and position only. - """ + """Get basic location information summary: identity and position only.""" return self.model_dump(include={"uuid", "position"}) diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index 4185348d..4a8712a9 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -313,27 +313,44 @@ def test_get_locations_accepts_valid_and_undeclared_parameters(test_app, query): assert response.status_code == 200 +# Fixture shared by the /api/locations and /api/locations/marker-styles tests +# below: point_type-categorized locker locations with a matching marker_styles +# config. Kept as data + a small factory, not one big db_overrides literal per +# test, so each test only states what it actually varies. +_LOCKER_LOCATIONS = [ + { + "name": "locker-1", + "position": [50, 50], + "point_type": "parcel_locker", + "uuid": "11111111-1111-1111-1111-111111111111", + }, + { + "name": "locker-2", + "position": [51, 51], + "point_type": "container", + "uuid": "22222222-2222-2222-2222-222222222222", + }, +] + + +def _create_marker_styles_test_app(data=_LOCKER_LOCATIONS, **db_overrides): + overrides = { + "categories": {"point_type": ["parcel_locker", "container"]}, + "location_obligatory_fields": [("point_type", "str"), ("name", "str")], + "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, + "data": data, + "visible_data": ["name", "point_type"], + } + overrides.update(db_overrides) + return create_test_app(db_overrides=overrides) + + def test_get_locations_omits_marker_style_field_values(): """/api/locations should not surface the field marker_styles.icon_field points at (e.g. a point-type category) - that value is fetched lazily via /api/locations/marker-styles, only once a marker is individually visible, instead of upfront for every location (see lazy-load-marker-styling-plan.md).""" - client = create_test_app( - db_overrides={ - "categories": {"point_type": ["parcel_locker", "container"]}, - "location_obligatory_fields": [("point_type", "str"), ("name", "str")], - "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, - "data": [ - { - "name": "locker-1", - "position": [50, 50], - "point_type": "parcel_locker", - "uuid": "11111111-1111-1111-1111-111111111111", - }, - ], - "visible_data": ["name", "point_type"], - } - ) + client = _create_marker_styles_test_app(data=_LOCKER_LOCATIONS[:1]) response = client.get("/api/locations") @@ -349,28 +366,7 @@ def test_get_locations_omits_marker_style_field_values(): def test_get_locations_marker_styles_returns_requested_uuids_styling(): """The lazy marker-styles endpoint returns just the marker_styles-relevant field values for the requested uuids, not the full location.""" - client = create_test_app( - db_overrides={ - "categories": {"point_type": ["parcel_locker", "container"]}, - "location_obligatory_fields": [("point_type", "str"), ("name", "str")], - "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, - "data": [ - { - "name": "locker-1", - "position": [50, 50], - "point_type": "parcel_locker", - "uuid": "11111111-1111-1111-1111-111111111111", - }, - { - "name": "locker-2", - "position": [51, 51], - "point_type": "container", - "uuid": "22222222-2222-2222-2222-222222222222", - }, - ], - "visible_data": ["name", "point_type"], - } - ) + client = _create_marker_styles_test_app() response = client.get("/api/locations/marker-styles?uuid=11111111-1111-1111-1111-111111111111") @@ -384,28 +380,7 @@ def test_get_locations_marker_styles_returns_requested_uuids_styling(): def test_get_locations_marker_styles_supports_multiple_uuids(): - client = create_test_app( - db_overrides={ - "categories": {"point_type": ["parcel_locker", "container"]}, - "location_obligatory_fields": [("point_type", "str"), ("name", "str")], - "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, - "data": [ - { - "name": "locker-1", - "position": [50, 50], - "point_type": "parcel_locker", - "uuid": "11111111-1111-1111-1111-111111111111", - }, - { - "name": "locker-2", - "position": [51, 51], - "point_type": "container", - "uuid": "22222222-2222-2222-2222-222222222222", - }, - ], - "visible_data": ["name", "point_type"], - } - ) + client = _create_marker_styles_test_app() response = client.get( "/api/locations/marker-styles" @@ -429,21 +404,8 @@ def test_get_locations_marker_styles_supports_multiple_uuids(): def test_get_locations_marker_styles_omits_unknown_uuids(): """An unknown/re-clustered-away uuid doesn't error the whole request - it's just absent from the response.""" - client = create_test_app( - db_overrides={ - "categories": {"point_type": ["parcel_locker"]}, - "location_obligatory_fields": [("point_type", "str"), ("name", "str")], - "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, - "data": [ - { - "name": "locker-1", - "position": [50, 50], - "point_type": "parcel_locker", - "uuid": "11111111-1111-1111-1111-111111111111", - }, - ], - "visible_data": ["name", "point_type"], - } + client = _create_marker_styles_test_app( + data=_LOCKER_LOCATIONS[:1], categories={"point_type": ["parcel_locker"]} ) response = client.get( From 3c0b60d574e709dd249f143007552e002548f24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 00:38:22 +0200 Subject: [PATCH 28/45] lot of files changes --- README.md | 1 - docs/configuration.rst | 18 +----- docs/data-source.rst | 11 +--- docs/quickstart.rst | 1 - e2e-tests/e2e_stress_test_config.yml | 1 - e2e-tests/e2e_test_config.template.yml | 1 - examples/e2e_test_config.yml | 1 - goodmap/db.py | 8 +-- goodmap/feature_flags.py | 5 -- goodmap/goodmap.py | 81 ++++++++------------------ goodmap/templates/goodmap-admin.html | 1 - goodmap/templates/map.html | 1 - tests/unit_tests/conftest.py | 4 +- tests/unit_tests/test_core_api.py | 1 - tests/unit_tests/test_goodmap.py | 22 ++++--- 15 files changed, 50 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 4faf2e3d..fdec3d60 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ Afterwards run it with: | Option | Description | |--------------------------|------------------------------------------------------------------------------------------------------------------------------------| -| USE_LAZY_LOADING | Loads point data only after the user clicks a point. If set to false, point data is loaded together with the initial map. | | FAKE_LOGIN | If set to true, allows access to the admin panel by simply selecting the role instead of logging in. **DO NOT USE IN PRODUCTION!** | | SHOW_ACCESSIBILITY_TABLE | If set as true it shows special view to help with accessing application. | diff --git a/docs/configuration.rst b/docs/configuration.rst index 4562b0d2..5bc840a5 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -60,7 +60,6 @@ Everything below in one file — copy it and delete what you do not need: max_size: 5242880 # 5 MiB FEATURE_FLAGS: - USE_LAZY_LOADING: true CATEGORIES_HELP: true SHOW_SEARCH_BAR: true SHOW_SUGGEST_NEW_POINT_BUTTON: true @@ -165,8 +164,7 @@ Basic keys Feature flags ------------- -``FEATURE_FLAGS`` is a flat mapping of flag name to boolean. Unset flags are off, with one -exception: ``USE_LAZY_LOADING`` defaults to on. +``FEATURE_FLAGS`` is a flat mapping of flag name to boolean. Unset flags are off. Flags fall into two groups: some change what the backend does, others are handed to the frontend to decide what to render. Both are set the same way. @@ -178,13 +176,6 @@ frontend to decide what to render. Both are set the same way. * - Flag - Acts on - Effect - * - ``USE_LAZY_LOADING`` - - backend - - **On by default.** Builds the location model from ``location_obligatory_fields`` - and ``categories`` in your data source, so submitted points are validated against - them, and the "suggest a new point" form is generated from them. Set it to - ``false`` and only ``uuid``, ``position`` and ``remark`` are validated, and the - suggest form has no fields — see the note below. * - ``CATEGORIES_HELP`` - both - Enables the help-tooltip data in ``/api/categories-full``, and makes the frontend @@ -219,13 +210,6 @@ frontend to decide what to render. Both are set the same way. Never enable ``FAKE_LOGIN`` in production. It hands a logged-in session to anyone who asks for one. -.. note:: - - ``USE_LAZY_LOADING`` is named for behaviour that is now unconditional: point details - have their own endpoint (``/api/location/``) whether the flag is set or not. - What the flag still controls is schema validation, as described above. Leave it on - unless you have a reason not to. - The frontend receives the whole ``FEATURE_FLAGS`` mapping, so a plugin or a custom build can read flags Goodmap itself does not know about. diff --git a/docs/data-source.rst b/docs/data-source.rst index 1c567376..bdf0e37d 100644 --- a/docs/data-source.rst +++ b/docs/data-source.rst @@ -27,8 +27,9 @@ and their schema, alongside platzky's ``site_content`` section: Note that ``plugins`` is a **sibling** of ``map``, not a key inside it. -Only ``data`` and ``categories`` are structurally required; ``suggestions`` and -``reports`` are created by the app as users submit things. +Only ``data`` is structurally required. ``categories`` defaults to no categories +if omitted (a map with only plain, unfiltered points is a valid setup); ``suggestions`` +and ``reports`` are created by the app as users submit things. Points ------ @@ -108,12 +109,6 @@ This drives three things at once: - **Length limits.** String fields are capped at 200 characters, lists at 20 items of at most 100 characters each. -.. important:: - - This key is only read when the ``USE_LAZY_LOADING`` feature flag is on. With it off, - nothing beyond ``uuid``/``position``/``remark`` is validated and the suggest form comes - up empty. See :ref:`config-feature-flags`. - .. _data-model-visible_data: ``visible_data`` and ``meta_data`` diff --git a/docs/quickstart.rst b/docs/quickstart.rst index e6c2fe08..7da7b986 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -131,7 +131,6 @@ Create ``config.yml`` next to it: PATH: data.json FEATURE_FLAGS: - USE_LAZY_LOADING: true SHOW_SEARCH_BAR: true SHOW_SUGGEST_NEW_POINT_BUTTON: true diff --git a/e2e-tests/e2e_stress_test_config.yml b/e2e-tests/e2e_stress_test_config.yml index 8cf34d8c..50aba4fe 100644 --- a/e2e-tests/e2e_stress_test_config.yml +++ b/e2e-tests/e2e_stress_test_config.yml @@ -19,7 +19,6 @@ LANGUAGES: country: PL FEATURE_FLAGS: - USE_LAZY_LOADING: True SHOW_ACCESSIBILITY_TABLE: True USE_SERVER_SIDE_CLUSTERING: False CATEGORIES_HELP: True diff --git a/e2e-tests/e2e_test_config.template.yml b/e2e-tests/e2e_test_config.template.yml index e73ba88d..f1571613 100644 --- a/e2e-tests/e2e_test_config.template.yml +++ b/e2e-tests/e2e_test_config.template.yml @@ -21,7 +21,6 @@ LANGUAGES: country: PL FEATURE_FLAGS: - USE_LAZY_LOADING: True SHOW_ACCESSIBILITY_TABLE: True USE_SERVER_SIDE_CLUSTERING: False CATEGORIES_HELP: True diff --git a/examples/e2e_test_config.yml b/examples/e2e_test_config.yml index f80e4970..4276a68c 100644 --- a/examples/e2e_test_config.yml +++ b/examples/e2e_test_config.yml @@ -16,7 +16,6 @@ LANGUAGES: country: PL FEATURE_FLAGS: - USE_LAZY_LOADING: True USE_SERVER_SIDE_CLUSTERING: False SHOW_ACCESSIBILITY_TABLE: True FAKE_LOGIN: False diff --git a/goodmap/db.py b/goodmap/db.py index cdd3ec8b..a1213650 100644 --- a/goodmap/db.py +++ b/goodmap/db.py @@ -667,7 +667,7 @@ def json_db_get_category_data(self, category_type=None): """Return category data from in-memory JSON database, optionally filtered by type.""" if category_type: return { - "categories": {category_type: self.data["categories"].get(category_type, [])}, + "categories": {category_type: self.data.get("categories", {}).get(category_type, [])}, "categories_help": self.data.get("categories_help", []), "categories_options_help": { category_type: self.data.get("categories_options_help", {}).get(category_type, []) @@ -682,7 +682,7 @@ def json_db_get_category_data(self, category_type=None): }, } return { - "categories": self.data["categories"], + "categories": self.data.get("categories", {}), "categories_help": self.data.get("categories_help", []), "categories_options_help": self.data.get("categories_options_help", {}), "categories_default_checked": self.data.get("categories_default_checked", {}), @@ -696,7 +696,7 @@ def json_file_db_get_category_data(self, category_type=None): data = json.load(file)["map"] if category_type: return { - "categories": {category_type: data["categories"].get(category_type, [])}, + "categories": {category_type: data.get("categories", {}).get(category_type, [])}, "categories_help": data.get("categories_help", []), "categories_options_help": { category_type: data.get("categories_options_help", {}).get(category_type, []) @@ -709,7 +709,7 @@ def json_file_db_get_category_data(self, category_type=None): }, } return { - "categories": data["categories"], + "categories": data.get("categories", {}), "categories_help": data.get("categories_help", []), "categories_options_help": data.get("categories_options_help", {}), "categories_default_checked": data.get("categories_default_checked", {}), diff --git a/goodmap/feature_flags.py b/goodmap/feature_flags.py index 61ffe81c..2d6abb0f 100644 --- a/goodmap/feature_flags.py +++ b/goodmap/feature_flags.py @@ -5,15 +5,10 @@ Flags: CategoriesHelp: Display help text alongside map categories to guide users. - UseLazyLoading: Defer loading of location fields until they are needed, - improving initial page load performance. EnableAdminPanel: Expose the admin panel for managing map data. """ from platzky import FeatureFlag CategoriesHelp = FeatureFlag(alias="CATEGORIES_HELP", description="Show category help text") -UseLazyLoading = FeatureFlag( - alias="USE_LAZY_LOADING", default=True, description="Enable lazy loading of location fields" -) EnableAdminPanel = FeatureFlag(alias="ENABLE_ADMIN_PANEL", description="Enable admin panel") diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index ff7fe89e..9bca9f96 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -13,7 +13,6 @@ from platzky.models import CmsModule from platzky.plugin.content_transformer import ContentTransformerPluginBase from platzky.shortcodes import Shortcode -from pydantic import BaseModel from goodmap.api.admin_api import admin_pages from goodmap.api.core_api import core_pages @@ -21,9 +20,11 @@ from goodmap.data_models.location import create_location_model from goodmap.db import ( extend_db_with_goodmap_queries, + get_category_data, get_location_obligatory_fields, + get_marker_styles, ) -from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading +from goodmap.feature_flags import EnableAdminPanel from goodmap.plugin import CAPABILITY_BASES, GoodmapPluginBase logger = logging.getLogger(__name__) @@ -110,51 +111,6 @@ def _add_cors(response): return None, [] -def _setup_location_model( - db: Any, -) -> tuple[list[Any], dict[str, Any], type[BaseModel], Any, frozenset[str]]: - """Configure location model and db with lazy-loading and categories support. - - Args: - db: The database instance to extend with location queries. - - Returns: - Tuple of (obligatory_fields, categories, location_model, db, pin_marker_fields). - pin_marker_fields is app-wiring knowledge - which of this deployment's fields - the marker_styles config (icon_field/color_field) actually points at - not - something the location model itself needs to know; it's threaded to core_pages() - for goodmap.api.api_models.marker_style_values() to use. - """ - obligatory_fields = get_location_obligatory_fields(db) - location_model = create_location_model(obligatory_fields, {}) - extended_db = extend_db_with_goodmap_queries(db, location_model) - - try: - category_data = extended_db.get_category_data() - categories = category_data.get("categories", {}) - except (KeyError, AttributeError): - categories = {} - - try: - marker_styles = extended_db.get_marker_styles() - except (KeyError, AttributeError): - marker_styles = {} - marker_style_fields = { - field - for field in (marker_styles.get("icon_field"), marker_styles.get("color_field")) - if field is not None - } - - if categories or marker_style_fields: - location_model = create_location_model(obligatory_fields, categories) - extended_db = extend_db_with_goodmap_queries(extended_db, location_model) - - field_names = {name for name, _ in obligatory_fields} - pin_marker_fields = frozenset(marker_style_fields) & field_names - - return obligatory_fields, categories, location_model, extended_db, pin_marker_fields - - def create_app(config_path: str) -> platzky.Engine: """Create Goodmap application from YAML configuration file. @@ -212,15 +168,28 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: if app.config.get("MAX_CONTENT_LENGTH") is None: app.config["MAX_CONTENT_LENGTH"] = config.attachment.max_size + MULTIPART_OVERHEAD_ALLOWANCE - if app.is_enabled(UseLazyLoading): - location_obligatory_fields, _, location_model, app.db, pin_marker_fields = ( - _setup_location_model(app.db) - ) - else: - location_obligatory_fields = [] - location_model = create_location_model([], {}) - app.db = extend_db_with_goodmap_queries(app.db, location_model) - pin_marker_fields = frozenset() + # Build this deployment's location model from its data source and extend app.db + # with the query functions it needs. categories/marker_styles are both optional + # (see docs/data-source.rst) - every backend's get_category_data()/ + # get_marker_styles() already defaults them to {} internally. + location_obligatory_fields = get_location_obligatory_fields(app.db) + categories = get_category_data(app.db)(app.db)["categories"] + marker_styles = get_marker_styles(app.db)(app.db) + marker_style_fields = { + field + for field in (marker_styles.get("icon_field"), marker_styles.get("color_field")) + if field is not None + } + + location_model = create_location_model(location_obligatory_fields, categories) + app.db = extend_db_with_goodmap_queries(app.db, location_model) + + obligatory_field_names = {name for name, _ in location_obligatory_fields} + # pin_marker_fields is app-wiring knowledge - which of this deployment's fields + # marker_styles.icon_field/color_field actually point at - not something the + # location model itself needs to know; threaded to core_pages() for + # goodmap.api.api_models.marker_style_values() to use. + pin_marker_fields = frozenset(marker_style_fields) & obligatory_field_names app.extensions["goodmap"] = {"location_obligatory_fields": location_obligatory_fields} diff --git a/goodmap/templates/goodmap-admin.html b/goodmap/templates/goodmap-admin.html index efd6bc85..3d4bcaf4 100644 --- a/goodmap/templates/goodmap-admin.html +++ b/goodmap/templates/goodmap-admin.html @@ -741,7 +741,6 @@

{{ gettext("Reports") }}

window.SHOW_SUGGEST_NEW_POINT_BUTTON = {{ feature_flags.SHOW_SUGGEST_NEW_POINT_BUTTON | default(false) | tojson }}; window.SHOW_SEARCH_BAR = {{ feature_flags.SHOW_SEARCH_BAR | default(false) | tojson }}; - window.USE_LAZY_LOADING = {{ feature_flags.USE_LAZY_LOADING | default(false) | tojson }}; window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }}; diff --git a/goodmap/templates/map.html b/goodmap/templates/map.html index 7ada444f..4852a8a9 100644 --- a/goodmap/templates/map.html +++ b/goodmap/templates/map.html @@ -116,7 +116,6 @@ window.SHOW_SUGGEST_NEW_POINT_BUTTON = {{ feature_flags.SHOW_SUGGEST_NEW_POINT_BUTTON | default(false) | tojson }}; window.SHOW_SEARCH_BAR = {{ feature_flags.SHOW_SEARCH_BAR | default(false) | tojson }}; -window.USE_LAZY_LOADING = {{ feature_flags.USE_LAZY_LOADING | default(false) | tojson }}; window.USE_SERVER_SIDE_CLUSTERING = {{ feature_flags.USE_SERVER_SIDE_CLUSTERING | default(false) | tojson }}; window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }}; window.FEATURE_FLAGS = {{ feature_flags | tojson }}; diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 55155568..2f97c2d7 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -5,7 +5,7 @@ from platzky import FeatureFlag, FeatureFlagSet from goodmap.config import GoodmapConfig -from goodmap.feature_flags import CategoriesHelp, EnableAdminPanel, UseLazyLoading +from goodmap.feature_flags import CategoriesHelp, EnableAdminPanel from goodmap.goodmap import create_app_from_config @@ -101,7 +101,7 @@ def multipart_suggest_post(client, location, photo=None): def create_test_app( - feature_flags=make_flag_set(CategoriesHelp, UseLazyLoading, EnableAdminPanel), + feature_flags=make_flag_set(CategoriesHelp, EnableAdminPanel), db_overrides=None, ): """Create a test app with optional feature flags and db overrides.""" diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index 4a8712a9..d8a3aab8 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -1173,7 +1173,6 @@ def test_issue_options_defaults_to_empty_when_missing(): config_data = get_test_config_data() config_data["FEATURE_FLAGS"] = { "CATEGORIES_HELP": True, - "USE_LAZY_LOADING": True, "ENABLE_ADMIN_PANEL": True, } config_data["DB"]["DATA"].pop("reported_issue_types", None) diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 7d3e654b..aa7b79dd 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -14,7 +14,7 @@ from goodmap import goodmap from goodmap.config import GoodmapConfig -from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading +from goodmap.feature_flags import EnableAdminPanel from goodmap.plugin import ( CAPABILITY_BASES, MapOverlayPluginBase, @@ -37,7 +37,14 @@ def test_create_app(): def test_create_app_from_config(): with patch("platzky.platzky.create_app_from_config", MagicMock()) as mock_platzky_app_creation: mock_platzky_app_creation.return_value.is_enabled.return_value = False - with patch("goodmap.goodmap.extend_db_with_goodmap_queries", MagicMock()) as mock_extend_db: + with ( + patch("goodmap.goodmap.extend_db_with_goodmap_queries", MagicMock()) as mock_extend_db, + patch("goodmap.goodmap.get_location_obligatory_fields", return_value=[]), + patch("goodmap.goodmap.get_category_data") as mock_get_category_data, + patch("goodmap.goodmap.get_marker_styles") as mock_get_marker_styles, + ): + mock_get_category_data.return_value.return_value = {"categories": {}} + mock_get_marker_styles.return_value.return_value = {} goodmap.create_app_from_config(config) mock_platzky_app_creation.assert_called_once_with( config, @@ -56,12 +63,13 @@ def test_create_app_delegation(mock_parse_yaml, mock_create_app_from_config): @mock.patch("goodmap.goodmap.get_location_obligatory_fields") -def test_use_lazy_loading_branch(mock_get_location_obligatory_fields): +def test_location_model_is_always_built_from_the_data_source(mock_get_location_obligatory_fields): + """Building the location model from location_obligatory_fields/categories is + unconditional - there's no flag that skips it (see feature_flags.py).""" config = GoodmapConfig( APP_NAME="test_lazy", SECRET_KEY="secret", DB=JsonDbConfig(DATA={"site_content": {}, "location_obligatory_fields": []}, TYPE="json"), - FEATURE_FLAGS=make_flag_set(UseLazyLoading), ) app = goodmap.create_app_from_config(config) @@ -284,8 +292,9 @@ def test_map_route_overrides_photo_constraints(): assert photo["allowed_extensions"] == ["jpeg", "jpg", "png"] -def test_location_schema_endpoint_with_lazy_loading(): - """The schema includes obligatory_fields when USE_LAZY_LOADING is enabled.""" +def test_location_schema_endpoint_includes_obligatory_fields(): + """The schema includes this deployment's obligatory_fields - unconditional, + there's no flag that skips building the location model from them.""" config = GoodmapConfig( APP_NAME="test_app", SECRET_KEY="test_secret", @@ -303,7 +312,6 @@ def test_location_schema_endpoint_with_lazy_loading(): }, TYPE="json", ), - FEATURE_FLAGS=make_flag_set(UseLazyLoading), ) app = goodmap.create_app_from_config(config) # CSRF protection must be disabled in test environment to allow API testing From b676b676b5f98726b5084d0ee96dc5f75e32fcb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 10:17:15 +0200 Subject: [PATCH 29/45] fixes --- e2e-tests/e2e_test_data_initial.json | 10 ++- e2e-tests/tests/basic/test_marker_styles.py | 19 +++--- frontend/jest.config.js | 1 + frontend/package-lock.json | 48 ++++++++++++++ frontend/package.json | 2 + .../MarkerPopup/getTypedMarkerIcon.jsx | 33 +++++++++- .../MarkerPopup/getTypedMarkerIcon.test.jsx | 63 +++++++++++++++++++ frontend/webpack.config.js | 15 +++++ 8 files changed, 179 insertions(+), 12 deletions(-) diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index a0c95225..14abbe29 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -276,8 +276,14 @@ "icon_field": "type_of_place", "color_field": "speed_limit", "icons": { - "big bridge": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg", - "small bridge": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/footprints-fill.svg" + "big bridge": { + "provider": "phosphor", + "value": "bridge" + }, + "small bridge": { + "provider": "phosphor", + "value": "footprints" + } }, "colors": { "10": "#2e7d32", diff --git a/e2e-tests/tests/basic/test_marker_styles.py b/e2e-tests/tests/basic/test_marker_styles.py index 62e3f200..575de046 100644 --- a/e2e-tests/tests/basic/test_marker_styles.py +++ b/e2e-tests/tests/basic/test_marker_styles.py @@ -13,15 +13,16 @@ from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, open_test_popup # "big bridge" and "small bridge" each get their own Phosphor Icons (MIT) type -# icon - see e2e_test_data_initial.json's marker_styles.icons and -# getTypedMarkerIcon.jsx (icon URLs are CSS mask-image'd onto the pin, tinted -# by the matched color, rather than embedded as inline SVG path data). -BIG_BRIDGE_TYPE_ICON_URL = ( - "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" -) -SMALL_BRIDGE_TYPE_ICON_URL = ( - "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/footprints-fill.svg" -) +# icon - see e2e_test_data_initial.json's marker_styles.icons ({provider: +# "phosphor", value: "..."}, resolved to a bundled, self-hosted asset - +# see resolvePhosphorIconUrl.js) and getTypedMarkerIcon.jsx (icon URLs are CSS +# mask-image'd onto the pin, tinted by the matched color, rather than embedded +# as inline SVG path data). +# Served by the frontend dev server (webpack-dev-server, see Makefile's +# run-frontend), not the backend BASE_URL - these are static frontend assets. +FRONTEND_URL = "http://localhost:8080" +BIG_BRIDGE_TYPE_ICON_URL = f"{FRONTEND_URL}/phosphor-icons/bridge-fill.svg" +SMALL_BRIDGE_TYPE_ICON_URL = f"{FRONTEND_URL}/phosphor-icons/footprints-fill.svg" class TestMarkerStyles: diff --git a/frontend/jest.config.js b/frontend/jest.config.js index a1330273..ded76dac 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -22,5 +22,6 @@ module.exports = { moduleNameMapper: { '\\.(css|less)$': '/__mocks__/styleMock.js', '\\.(png|jpg|jpeg|gif|svg)$': '/__mocks__/fileMock.js', + 'resolvePhosphorIconUrl$': '/__mocks__/resolvePhosphorIconUrlMock.js', }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e22d659..092e0445 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.6", "@mui/material": "^5.14.6", + "@phosphor-icons/core": "^2.1.1", "@react-leaflet/core": "^2.1.0", "axios": "^1.7.6", "browser-image-compression": "^2.0.2", @@ -49,6 +50,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.11", "eslint-plugin-react-hooks": "^4.6.0", + "file-loader": "^6.2.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "jest-fail-on-console": "^3.3.1", @@ -3827,6 +3829,12 @@ "node": ">=20.0.0" } }, + "node_modules/@phosphor-icons/core": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@phosphor-icons/core/-/core-2.1.1.tgz", + "integrity": "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7938,6 +7946,46 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f5d15adc..1c106bdc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.11", "eslint-plugin-react-hooks": "^4.6.0", + "file-loader": "^6.2.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "jest-fail-on-console": "^3.3.1", @@ -63,6 +64,7 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.6", "@mui/material": "^5.14.6", + "@phosphor-icons/core": "^2.1.1", "@react-leaflet/core": "^2.1.0", "axios": "^1.7.6", "browser-image-compression": "^2.0.2", diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index cdbc1dc1..3f5853c7 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { DivIcon } from 'leaflet'; import ReactDOMServer from 'react-dom/server'; import PIN_SHAPE_URL from '../../res/svg/marker-pin.svg'; +import resolvePhosphorIconUrl from './resolvePhosphorIconUrl'; const PIN_WIDTH = 45; const PIN_HEIGHT = 50; @@ -14,6 +15,32 @@ const TYPE_ICON_SIZE = 20; const TYPE_ICON_OFFSET_TOP = 8; const TYPE_ICON_OFFSET_LEFT = 12; +/** + * Resolves a configured marker_styles.icons entry to a usable URL. Supports a + * plain string (a direct URL, as before) and a tagged {provider, value} object: + * provider "phosphor" resolves value (an icon name) against the bundled + * @phosphor-icons/core set - no external request; provider "url" is the same + * as a plain string, spelled out explicitly. + * + * @param {string|{provider: string, value: string}|undefined} icon + * @returns {string} A usable URL, or '' if icon is unset or unresolvable + */ +const resolveIconUrl = icon => { + if (!icon) { + return ''; + } + if (typeof icon === 'string') { + return icon; + } + if (icon.provider === 'phosphor') { + return resolvePhosphorIconUrl(icon.value); + } + if (icon.provider === 'url') { + return icon.value || ''; + } + return ''; +}; + const maskStyle = (url, color) => ({ backgroundColor: color, WebkitMaskImage: `url(${url})`, @@ -89,6 +116,10 @@ PinIcon.propTypes = { * matched, or `null` (falls back to Leaflet's default marker) when there's * neither a match nor a remark to show. * + * Each entry in MARKER_STYLES.icons is either a plain URL string, or a tagged + * {provider: "phosphor", value: ""} / {provider: "url", value: ""} + * object - see resolveIconUrl. + * * @param {Object} place - Location data from GET /api/locations, merged with any * styling lazily fetched for it from GET /api/locations/marker-styles (has_remark * and marker_styles field values aren't in the initial /api/locations response - @@ -99,7 +130,7 @@ const getTypedMarkerIcon = place => { const markerStyles = globalThis.MARKER_STYLES || {}; const { icon_field: iconField, color_field: colorField, icons, colors } = markerStyles; - const typeIconUrl = icons?.[place[iconField]] || ''; + const typeIconUrl = resolveIconUrl(icons?.[place[iconField]]); const matchedColor = colors?.[place[colorField]] || ''; const hasRemark = Boolean(place.has_remark); diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index abf79e98..e1d98a34 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -1,4 +1,8 @@ import getTypedMarkerIcon from '../../src/components/MarkerPopup/getTypedMarkerIcon'; +// Mocked project-wide (see jest.config.js's moduleNameMapper) since the real +// implementation uses webpack's require.context, which plain Babel/Jest can't +// evaluate. +import resolvePhosphorIconUrl from '../../src/components/MarkerPopup/resolvePhosphorIconUrl'; // window.MARKER_STYLES is server-rendered JSON (see goodmap's map.html/db.get_marker_styles), // so fixtures are parsed from JSON strings here too - keeps the snake_case backend field @@ -170,3 +174,62 @@ describe('getTypedMarkerIcon', () => { expect(icon.options.html).toContain('background-color:black'); }); }); + +describe('getTypedMarkerIcon icon value shapes', () => { + afterEach(() => { + delete globalThis.MARKER_STYLES; + resolvePhosphorIconUrl.mockReset(); + }); + + it('resolves a {provider: "phosphor", value} entry via resolvePhosphorIconUrl', () => { + resolvePhosphorIconUrl.mockReturnValue( + '/static/phosphor-icons/shipping-container-fill.svg', + ); + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "container": { "provider": "phosphor", "value": "shipping-container" } } + }`); + + const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); + + expect(resolvePhosphorIconUrl).toHaveBeenCalledWith('shipping-container'); + expect(icon.options.html).toContain( + 'mask-image:url(/static/phosphor-icons/shipping-container-fill.svg)', + ); + }); + + it('resolves a {provider: "url", value} entry as a plain URL, without touching phosphor', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "container": { "provider": "url", "value": "https://cdn.example.com/c.svg" } } + }`); + + const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); + + expect(resolvePhosphorIconUrl).not.toHaveBeenCalled(); + expect(icon.options.html).toContain('mask-image:url(https://cdn.example.com/c.svg)'); + }); + + it('still accepts a plain string entry as a direct URL, unchanged from before', () => { + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "container": "https://cdn.example.com/c.svg" } + }`); + + const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); + + expect(icon.options.html).toContain('mask-image:url(https://cdn.example.com/c.svg)'); + }); + + it('treats an unresolvable phosphor icon name as no icon, not a broken URL', () => { + resolvePhosphorIconUrl.mockReturnValue(''); + setMarkerStyles(`{ + "icon_field": "pointType", + "icons": { "container": { "provider": "phosphor", "value": "not-a-real-icon" } } + }`); + + const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); + + expect(icon).toBeNull(); + }); +}); diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index aed77ad2..55bc09ee 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -54,12 +54,27 @@ module.exports = (env, argv) => { }, { test: /\.(jpe?g|png|gif|woff|woff2|eot|ttf|svg)$/i, + exclude: /@phosphor-icons\/core/, loader: 'url-loader', options: { limit: 8192, name: '[path][name].[ext]', }, }, + { + // @phosphor-icons/core's full icon set is pulled in via + // require.context (see resolvePhosphorIconUrl.js) so any icon + // can be referenced by name from deployment config, without a + // frontend code change - never inline these (limit: 0), or that + // whole set would bloat the main JS bundle instead of staying as + // separate files fetched only for icons actually rendered. + test: /@phosphor-icons\/core.*\.svg$/i, + loader: 'url-loader', + options: { + limit: 0, + name: 'phosphor-icons/[name].[ext]', + }, + }, ], }, devServer: { From ff4dcd890f925973b475914be228068fc4a25ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 13:08:13 +0200 Subject: [PATCH 30/45] a lot of refactor --- docs/data-source.rst | 49 +++- docs/http-api.rst | 16 +- e2e-tests/tests/basic/test_marker_styles.py | 12 +- frontend/jest.config.js | 1 - frontend/package-lock.json | 12 +- frontend/package.json | 2 - .../Map/store/markerStyles.store.js | 14 - .../components/MarkerPopup/MarkerPopup.jsx | 19 +- .../MarkerPopup/getTypedMarkerIcon.jsx | 34 ++- .../MarkerPopup/requestMarkerStyle.js | 54 ---- .../MarkerPopup/resolvePhosphorIconUrl.js | 15 + frontend/src/services/http/endpoints.js | 8 - frontend/src/services/http/httpService.js | 27 -- .../MarkerClusterGroupIntegration.test.jsx | 4 +- .../tests/MarkerPopup/MarkerPopup.test.jsx | 102 +------ .../MarkerPopup/getTypedMarkerIcon.test.jsx | 112 ++++---- .../MarkerPopup/requestMarkerStyle.test.js | 66 ----- frontend/webpack.config.js | 23 +- goodmap/api/api_models.py | 79 ++++-- goodmap/api/core_api.py | 52 ++-- goodmap/clustering.py | 21 +- goodmap/goodmap.py | 30 +- poetry.lock | 263 +++++++++++++++++- pyproject.toml | 1 + tests/unit_tests/data_models/test_location.py | 6 +- tests/unit_tests/test_api_models.py | 41 ++- tests/unit_tests/test_clustering.py | 28 +- tests/unit_tests/test_core_api.py | 167 +++-------- 28 files changed, 624 insertions(+), 634 deletions(-) delete mode 100644 frontend/src/components/Map/store/markerStyles.store.js delete mode 100644 frontend/src/components/MarkerPopup/requestMarkerStyle.js create mode 100644 frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js delete mode 100644 frontend/tests/MarkerPopup/requestMarkerStyle.test.js diff --git a/docs/data-source.rst b/docs/data-source.rst index bdf0e37d..79b3325e 100644 --- a/docs/data-source.rst +++ b/docs/data-source.rst @@ -64,9 +64,9 @@ ordinary field of your own: Used as the marker popup's **subtitle**. ``remark`` (optional) - Free text. Its presence — not its content — is exposed by ``/api/locations`` as a - boolean, so the frontend can flag points that have something noteworthy without - fetching them all. + Free text. Its presence — not its content — is exposed by ``/api/locations`` as + ``marker.badge: true`` (see :ref:`data-source-marker-styles`), so the frontend can + flag points that have something noteworthy without fetching them all. Everything else is yours. Custom fields are only *shown* if you list them in ``visible_data``, and only *filterable* if you list them in ``categories``. @@ -255,6 +255,49 @@ Each category's active mode is exposed as ``filter_mode`` in the ``/api/categories-full`` response, so a custom frontend can render the right control — checkbox or radio — without hardcoding category names. +.. _data-source-marker-styles: + +Marker styles +------------- + +``marker_styles`` picks which of your fields drive each point's pin icon and color, and +supplies the lookup tables the frontend resolves them through. It is entirely optional — +a map with no ``marker_styles`` still renders, just with plain pins. + +.. code-block:: json + + { + "marker_styles": { + "icon_field": "type_of_place", + "color_field": "transparency", + "icons": { + "big bridge": "https://cdn.example.com/bridge.svg", + "container": {"provider": "phosphor", "value": "shipping-container"} + }, + "colors": { + "lacking": "#c62828", + "full": "#2e7d32" + } + } + } + +``icon_field``, ``color_field`` + Names of fields on your points (typically ones already listed in ``categories``) + whose *value* selects the icon/color for that point. Either or both may be omitted. + +``icons`` + Maps a value of ``icon_field`` to either a plain URL string, or + ``{"provider": "phosphor", "value": ""}`` to use a `Phosphor + `_ icon by name instead of hosting your own SVG. + +``colors`` + Maps a value of ``color_field`` to a CSS color. + +A point whose ``icon_field``/``color_field`` value has no entry in ``icons``/``colors`` +simply renders without that part of the styling — this is not an error. A point with a +``remark`` (see above) always gets the asterisk badge regardless of whether its icon/color +matched anything. + User submissions ---------------- diff --git a/docs/http-api.rst b/docs/http-api.rst index 49023dd6..a362a08a 100644 --- a/docs/http-api.rst +++ b/docs/http-api.rst @@ -126,8 +126,11 @@ Query parameters: curl 'http://localhost:5000/api/locations?accessible_by=bikes&lat=51.10&lon=17.05&limit=5' -Each point comes back as ``uuid``, ``position`` and ``has_remark`` — a **boolean**, whether -the point has a remark, not its text. +Each point comes back as ``uuid`` and ``position``, plus a ``marker`` object with the pin +styling: ``icon``/``color`` (the raw values of whichever fields this deployment's +``marker_styles`` config names, see :ref:`data-source-marker-styles`) and ``badge: true`` +when the point has a remark. ``marker`` is left out entirely when none of that applies to +a point, and inside it each key is left out rather than sent as ``null``/``false``. A ``lat``, ``lon`` or ``limit`` that cannot mean anything — not a number, or outside the range above — is a ``400 {"message": "Invalid request data"}`` rather than a silently @@ -147,10 +150,11 @@ Takes every parameter of :ref:`api-locations`, plus ``zoom`` (integer, **0–16* bad ``lat``, is a ``400``. Points and clusters come back in one list, told apart by ``type``. A ``"point"`` carries -a real ``uuid`` you can pass to :ref:`api-location-detail`; a ``"cluster"`` carries a -freshly-generated ``cluster_uuid`` (not stable across requests — it is a render key, not -an identifier) and the number of points it stands for. ``position`` is -``[latitude, longitude]``, as everywhere else. +a real ``uuid`` you can pass to :ref:`api-location-detail`, plus the same ``marker`` object +as ``/api/locations``; a ``"cluster"`` carries a freshly-generated ``cluster_uuid`` (not +stable across requests — it is a render key, not an identifier) and the number of points +it stands for, but no ``marker``. ``position`` is ``[latitude, longitude]``, as everywhere +else. .. _api-location-detail: diff --git a/e2e-tests/tests/basic/test_marker_styles.py b/e2e-tests/tests/basic/test_marker_styles.py index 575de046..3d98d5f1 100644 --- a/e2e-tests/tests/basic/test_marker_styles.py +++ b/e2e-tests/tests/basic/test_marker_styles.py @@ -14,15 +14,13 @@ # "big bridge" and "small bridge" each get their own Phosphor Icons (MIT) type # icon - see e2e_test_data_initial.json's marker_styles.icons ({provider: -# "phosphor", value: "..."}, resolved to a bundled, self-hosted asset - -# see resolvePhosphorIconUrl.js) and getTypedMarkerIcon.jsx (icon URLs are CSS +# "phosphor", value: "..."}, resolved to a jsdelivr CDN URL - see +# resolvePhosphorIconUrl.js) and getTypedMarkerIcon.jsx (icon URLs are CSS # mask-image'd onto the pin, tinted by the matched color, rather than embedded # as inline SVG path data). -# Served by the frontend dev server (webpack-dev-server, see Makefile's -# run-frontend), not the backend BASE_URL - these are static frontend assets. -FRONTEND_URL = "http://localhost:8080" -BIG_BRIDGE_TYPE_ICON_URL = f"{FRONTEND_URL}/phosphor-icons/bridge-fill.svg" -SMALL_BRIDGE_TYPE_ICON_URL = f"{FRONTEND_URL}/phosphor-icons/footprints-fill.svg" +PHOSPHOR_ICONS_CDN_BASE = "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill" +BIG_BRIDGE_TYPE_ICON_URL = f"{PHOSPHOR_ICONS_CDN_BASE}/bridge-fill.svg" +SMALL_BRIDGE_TYPE_ICON_URL = f"{PHOSPHOR_ICONS_CDN_BASE}/footprints-fill.svg" class TestMarkerStyles: diff --git a/frontend/jest.config.js b/frontend/jest.config.js index ded76dac..a1330273 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -22,6 +22,5 @@ module.exports = { moduleNameMapper: { '\\.(css|less)$': '/__mocks__/styleMock.js', '\\.(png|jpg|jpeg|gif|svg)$': '/__mocks__/fileMock.js', - 'resolvePhosphorIconUrl$': '/__mocks__/resolvePhosphorIconUrlMock.js', }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 092e0445..3f4eec7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,6 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.6", "@mui/material": "^5.14.6", - "@phosphor-icons/core": "^2.1.1", "@react-leaflet/core": "^2.1.0", "axios": "^1.7.6", "browser-image-compression": "^2.0.2", @@ -50,7 +49,6 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.11", "eslint-plugin-react-hooks": "^4.6.0", - "file-loader": "^6.2.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "jest-fail-on-console": "^3.3.1", @@ -3829,12 +3827,6 @@ "node": ">=20.0.0" } }, - "node_modules/@phosphor-icons/core": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@phosphor-icons/core/-/core-2.1.1.tgz", - "integrity": "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==", - "license": "MIT" - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7952,6 +7944,8 @@ "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" @@ -7973,6 +7967,8 @@ "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", diff --git a/frontend/package.json b/frontend/package.json index 1c106bdc..f5d15adc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,7 +47,6 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.11", "eslint-plugin-react-hooks": "^4.6.0", - "file-loader": "^6.2.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "jest-fail-on-console": "^3.3.1", @@ -64,7 +63,6 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.6", "@mui/material": "^5.14.6", - "@phosphor-icons/core": "^2.1.1", "@react-leaflet/core": "^2.1.0", "axios": "^1.7.6", "browser-image-compression": "^2.0.2", diff --git a/frontend/src/components/Map/store/markerStyles.store.js b/frontend/src/components/Map/store/markerStyles.store.js deleted file mode 100644 index 8608056a..00000000 --- a/frontend/src/components/Map/store/markerStyles.store.js +++ /dev/null @@ -1,14 +0,0 @@ -import { create } from 'zustand'; - -/** - * uuid -> resolved marker-styling field values (whatever marker_styles.icon_field/ - * color_field point at), lazily fetched once a client-side-clustered marker becomes - * individually visible - see lazy-load-marker-styling-plan.md. A uuid with no - * matching styling is still recorded, as {}, so it isn't re-requested forever. - */ -const useMarkerStylesStore = create(set => ({ - stylesByUuid: {}, - mergeStyles: styles => set(state => ({ stylesByUuid: { ...state.stylesByUuid, ...styles } })), -})); - -export default useMarkerStylesStore; diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index 60ae7752..d06e9dda 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -5,13 +5,11 @@ import { isMobile } from 'react-device-detect'; import { useTranslation } from 'react-i18next'; import httpService from '../../services/http/httpService'; import useMapStore from '../Map/store/map.store'; -import useMarkerStylesStore from '../Map/store/markerStyles.store'; import LocationDetailsBox from './LocationDetails'; import MobilePopup from './MobilePopup'; import DesktopPopup from './DesktopPopup'; import getTypedMarkerIcon from './getTypedMarkerIcon'; -import requestMarkerStyle from './requestMarkerStyle'; /** * Wrapper component that fetches full location details and renders them in a popup. @@ -77,13 +75,12 @@ LocationDetailsBoxWrapper.propTypes = { * @param {Object} props - Component props * @param {Object} props.place - Location data object * @param {number[]} props.place.position - Coordinates [latitude, longitude] - * @param {boolean} [props.place.has_remark] - Whether this location has a remark (adds an asterisk badge if true) + * @param {Object} [props.place.marker] - Pin styling; marker.badge true adds an asterisk badge * @returns {React.ReactElement} Leaflet Marker component with click-to-show-details functionality */ const MarkerPopup = ({ place }) => { const selectedLocationId = useMapStore(state => state.selectedLocationId); const setSelectedLocationId = useMapStore(state => state.setSelectedLocationId); - const lazyMarkerStyle = useMarkerStylesStore(state => state.stylesByUuid[place.uuid]); const [isClicked, setIsClicked] = useState(false); // TODO: this only opens the popup if `place`'s Marker is actually attached to @@ -105,20 +102,14 @@ const MarkerPopup = ({ place }) => { setIsClicked(true); }; - const handleMarkerVisible = () => { - requestMarkerStyle(place.uuid); - }; - const markerProps = { position: place.position, eventHandlers: { click: handleMarkerClick, - add: handleMarkerVisible, }, }; - const styledPlace = lazyMarkerStyle ? { ...place, ...lazyMarkerStyle } : place; - const typedIcon = getTypedMarkerIcon(styledPlace); + const typedIcon = getTypedMarkerIcon(place); if (typedIcon) { markerProps.icon = typedIcon; } @@ -134,7 +125,11 @@ const MarkerPopup = ({ place }) => { MarkerPopup.propTypes = { place: PropTypes.shape({ position: PropTypes.arrayOf(PropTypes.number).isRequired, - has_remark: PropTypes.bool, // eslint-disable-line camelcase -- matches backend API schema property name + marker: PropTypes.shape({ + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), + color: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), + badge: PropTypes.bool, + }), uuid: PropTypes.string.isRequired, }).isRequired, }; diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 3f5853c7..4b724431 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -7,7 +7,7 @@ import resolvePhosphorIconUrl from './resolvePhosphorIconUrl'; const PIN_WIDTH = 45; const PIN_HEIGHT = 50; -// The marker's default color (used whenever color_field doesn't match) is +// The marker's default color (used whenever marker.color doesn't match) is // always the page's own secondary color, not a separately configurable value. const FALLBACK_COLOR = globalThis.SECONDARY_COLOR || 'black'; @@ -18,12 +18,12 @@ const TYPE_ICON_OFFSET_LEFT = 12; /** * Resolves a configured marker_styles.icons entry to a usable URL. Supports a * plain string (a direct URL, as before) and a tagged {provider, value} object: - * provider "phosphor" resolves value (an icon name) against the bundled - * @phosphor-icons/core set - no external request; provider "url" is the same - * as a plain string, spelled out explicitly. + * provider "phosphor" builds a jsdelivr CDN URL for value (an icon name), see + * resolvePhosphorIconUrl; provider "url" is the same as a plain string, spelled + * out explicitly. * * @param {string|{provider: string, value: string}|undefined} icon - * @returns {string} A usable URL, or '' if icon is unset or unresolvable + * @returns {string} A usable URL, or '' if icon is unset */ const resolveIconUrl = icon => { if (!icon) { @@ -111,28 +111,26 @@ PinIcon.propTypes = { /** * Builds a Leaflet icon for `place`: colored/typed from the deployment's * marker styling lookup table (window.MARKER_STYLES, see goodmap's - * db.get_marker_styles) when it matches, our own pin in the fallback color - * with just the asterisk badge when `place.has_remark` is set but nothing - * matched, or `null` (falls back to Leaflet's default marker) when there's - * neither a match nor a remark to show. + * db.get_marker_styles) when `place.marker`'s icon/color match an entry, + * our own pin in the fallback color with just the asterisk badge when + * `place.marker.badge` is set but nothing matched, or `null` (falls back to + * Leaflet's default marker) when there's neither a match nor a badge to show. * * Each entry in MARKER_STYLES.icons is either a plain URL string, or a tagged * {provider: "phosphor", value: ""} / {provider: "url", value: ""} * object - see resolveIconUrl. * - * @param {Object} place - Location data from GET /api/locations, merged with any - * styling lazily fetched for it from GET /api/locations/marker-styles (has_remark - * and marker_styles field values aren't in the initial /api/locations response - - * see lazy-load-marker-styling-plan.md) + * @param {Object} place - Location data, as returned by GET /api/locations + * @param {Object} [place.marker] - Pin styling: {icon, color, badge} * @returns {import('leaflet').DivIcon|null} */ const getTypedMarkerIcon = place => { - const markerStyles = globalThis.MARKER_STYLES || {}; - const { icon_field: iconField, color_field: colorField, icons, colors } = markerStyles; + const { icons, colors } = globalThis.MARKER_STYLES || {}; + const marker = place.marker || {}; - const typeIconUrl = resolveIconUrl(icons?.[place[iconField]]); - const matchedColor = colors?.[place[colorField]] || ''; - const hasRemark = Boolean(place.has_remark); + const typeIconUrl = resolveIconUrl(icons?.[marker.icon]); + const matchedColor = colors?.[marker.color] || ''; + const hasRemark = Boolean(marker.badge); if (!typeIconUrl && !matchedColor && !hasRemark) { return null; diff --git a/frontend/src/components/MarkerPopup/requestMarkerStyle.js b/frontend/src/components/MarkerPopup/requestMarkerStyle.js deleted file mode 100644 index 59d6029d..00000000 --- a/frontend/src/components/MarkerPopup/requestMarkerStyle.js +++ /dev/null @@ -1,54 +0,0 @@ -import httpService from '../../services/http/httpService'; -import useMarkerStylesStore from '../Map/store/markerStyles.store'; - -const BATCH_DEBOUNCE_MS = 150; - -let pendingUuids = new Set(); -let timer = null; - -/** - * Queues `uuid` for a batched GET /api/locations/marker-styles fetch, once its - * marker becomes individually visible (not folded into a cluster) - see - * lazy-load-marker-styling-plan.md. Fetches pin styling data (has_remark plus any - * marker_styles field values), so it's needed regardless of whether marker_styles - * is even configured - has_remark alone still drives the asterisk badge. Debounced - * so that markers becoming visible in quick succession (panning, zooming, a - * cluster spiderfying) share one request instead of firing one per marker. - * - * Scoped to client-side clustering for now - server-side clustering's own - * lazy-loading trigger is a separate follow-up (see the plan doc). - * - * @param {string} uuid - Location UUID whose marker just became individually visible - */ -const requestMarkerStyle = uuid => { - if (globalThis.FEATURE_FLAGS?.USE_SERVER_SIDE_CLUSTERING) { - return; - } - - const alreadyKnown = uuid in useMarkerStylesStore.getState().stylesByUuid; - if (alreadyKnown || pendingUuids.has(uuid)) { - return; - } - pendingUuids.add(uuid); - - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(() => { - const uuids = [...pendingUuids]; - pendingUuids = new Set(); - timer = null; - - httpService - .getMarkerStyles(uuids) - .then(styles => { - // Every requested uuid is recorded, even with no matching styling - // ({}), so it isn't queued again on the next re-cluster. - const withDefaults = Object.fromEntries(uuids.map(u => [u, styles[u] ?? {}])); - useMarkerStylesStore.getState().mergeStyles(withDefaults); - }) - .catch(error => console.error('Failed to fetch marker styles:', error)); - }, BATCH_DEBOUNCE_MS); -}; - -export default requestMarkerStyle; diff --git a/frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js b/frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js new file mode 100644 index 00000000..a3d7c1b4 --- /dev/null +++ b/frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js @@ -0,0 +1,15 @@ +const PHOSPHOR_ICONS_CDN_BASE = 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill'; + +/** + * Resolves a phosphor icon name (kebab-case, e.g. "shipping-container") to its + * "fill" weight SVG URL on the Phosphor Icons CDN (MIT-licensed, jsdelivr-hosted). + * Not validated against the actual icon set - an unknown name just 404s in the + * browser when the mask-image is requested, same as any other misconfigured URL. + * + * @param {string} name - Icon name, matching a filename in + * @phosphor-icons/core/assets/fill without its "-fill.svg" suffix + * @returns {string} The icon's CDN URL + */ +const resolvePhosphorIconUrl = name => `${PHOSPHOR_ICONS_CDN_BASE}/${name}-fill.svg`; + +export default resolvePhosphorIconUrl; diff --git a/frontend/src/services/http/endpoints.js b/frontend/src/services/http/endpoints.js index 6f53f501..282b60fa 100644 --- a/frontend/src/services/http/endpoints.js +++ b/frontend/src/services/http/endpoints.js @@ -28,14 +28,6 @@ export const LOCATIONS = '/api/locations'; */ export const LOCATIONS_CLUSTERED = '/api/locations-clustered'; -/** - * API endpoint for lazily fetching marker styling field values (whatever - * marker_styles.icon_field/color_field point at) for specific locations, by uuid. - * Used once a location's marker becomes individually visible, instead of upfront - * for every location - see lazy-load-marker-styling-plan.md. - */ -export const LOCATIONS_MARKER_STYLES = '/api/locations/marker-styles'; - /** * External API endpoint for address search (forward geocoding) using OpenStreetMap Nominatim. * Converts addresses/place names to geographic coordinates. diff --git a/frontend/src/services/http/httpService.js b/frontend/src/services/http/httpService.js index 0962b8a6..01999f3c 100644 --- a/frontend/src/services/http/httpService.js +++ b/frontend/src/services/http/httpService.js @@ -5,7 +5,6 @@ import { LOCATIONS, SEARCH_ADDRESS, LOCATIONS_CLUSTERED, - LOCATIONS_MARKER_STYLES, } from './endpoints'; import useMapStore from '../../components/Map/store/map.store'; @@ -208,32 +207,6 @@ const httpService = { } }, - /** - * Fetches marker styling field values for specific locations, by uuid. - * Used to lazily fetch pin icon/color data once a marker becomes individually - * visible, instead of upfront for every location. - * - * @param {string[]} uuids - Location UUIDs to fetch styling for - * @returns {Promise>} Promise resolving to a map of - * uuid -> styling field values; uuids with no styling are simply absent - */ - getMarkerStyles: async uuids => { - if (!uuids.length) { - return {}; - } - const params = new URLSearchParams(); - for (const uuid of uuids) { - params.append('uuid', uuid); - } - const response = await fetch(`${LOCATIONS_MARKER_STYLES}?${params.toString()}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - return jsonOrThrow(response, 'marker styles'); - }, - /** * Searches for addresses using OpenStreetMap Nominatim API. * Returns up to 5 results with geocoded coordinates. diff --git a/frontend/tests/MarkerPopup/MarkerClusterGroupIntegration.test.jsx b/frontend/tests/MarkerPopup/MarkerClusterGroupIntegration.test.jsx index 26b446fd..086448ae 100644 --- a/frontend/tests/MarkerPopup/MarkerClusterGroupIntegration.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerClusterGroupIntegration.test.jsx @@ -24,17 +24,15 @@ describe('MarkerPopup integration with MarkerClusterGroup', () => { { position: [51.1095, 17.0525], uuid: 'location-1', - has_remark: false, // eslint-disable-line camelcase }, { position: [51.10655, 17.0555], uuid: 'location-2', - has_remark: true, // eslint-disable-line camelcase + marker: { badge: true }, }, { position: [51.1085, 17.0535], uuid: 'location-3', - has_remark: false, // eslint-disable-line camelcase }, ]; diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index 79f2dfa2..c0da8e4b 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -4,14 +4,12 @@ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { MapContainer } from 'react-leaflet'; import MarkerPopup from '../../src/components/MarkerPopup/MarkerPopup'; import httpService from '../../src/services/http/httpService'; -import useMarkerStylesStore from '../../src/components/Map/store/markerStyles.store'; jest.mock('../../src/services/http/httpService'); const location = { position: [51.1095, 17.0525], uuid: '21231', - has_remark: false, // eslint-disable-line camelcase -- matches backend API schema property name }; const locationData = { @@ -36,23 +34,6 @@ const locationData = { }; httpService.getLocation.mockResolvedValue(locationData); -// Every mount now fires a lazy marker-styles request (see requestMarkerStyle.js) - -// a harmless default so it always resolves, even in tests that don't care about it. -httpService.getMarkerStyles.mockResolvedValue({}); - -/** - * requestMarkerStyle.js debounces/batches uuids through module-level state shared - * by every test in this file. Describes below that render with real timers must - * drain that debounce window before finishing, or its still-pending timer fires - * during a later (fake-timer) describe and merges its uuid into that batch. - */ -const flushMarkerStyleDebounce = () => - act( - () => - new Promise(resolve => { - setTimeout(resolve, 200); - }), - ); describe('MarkerPopup', () => { beforeEach(() => { @@ -72,9 +53,8 @@ describe('MarkerPopup', () => { ); }); - afterEach(async () => { + afterEach(() => { globalThis.fetch.mockRestore(); - await flushMarkerStyleDebounce(); }); it('should render marker without popup', () => { @@ -120,14 +100,12 @@ describe('MarkerPopup with remark', () => { }); }); - afterEach(async () => { + afterEach(() => { globalThis.fetch.mockRestore(); - await flushMarkerStyleDebounce(); }); it('should render our own pin with an asterisk badge when remark is true', () => { - // eslint-disable-next-line camelcase -- matches backend API schema property name - const locationWhenRemarkIsTrue = { ...location, has_remark: true }; + const locationWhenRemarkIsTrue = { ...location, marker: { badge: true } }; act(() => { render( { }); it('should pass custom icon prop when remark is true', () => { - // eslint-disable-next-line camelcase -- matches backend API schema property name - const locationWithRemark = { ...location, has_remark: true }; + const locationWithRemark = { ...location, marker: { badge: true } }; act(() => { render( { expect(style.height).toBe('50px'); }); }); - -describe('MarkerPopup lazy marker styling', () => { - // A uuid distinct from `location`'s (used by the describes above, which run with - // real timers) so a leftover real setTimeout from those can't resolve into this - // describe's store state mid-test and make "already known" skip our own request. - const lazyLocation = { - position: [51.2, 17.1], - uuid: 'lazy-marker-styling-uuid', - has_remark: false, // eslint-disable-line camelcase -- matches backend API schema property name - }; - - beforeEach(() => { - jest.useFakeTimers(); - useMarkerStylesStore.setState({ stylesByUuid: {} }); - globalThis.MARKER_STYLES = { - icon_field: 'pointType', // eslint-disable-line camelcase -- matches backend API schema property name - icons: { parcelLocker: 'https://cdn.example.com/parcel-locker.svg' }, - }; - httpService.getMarkerStyles.mockResolvedValue({ - [lazyLocation.uuid]: { pointType: 'parcelLocker' }, - }); - }); - - afterEach(() => { - jest.useRealTimers(); - delete globalThis.MARKER_STYLES; - }); - - // render() already wraps itself in act(), so there's nothing left for a caller - // to flush - wrapping it again is redundant (and duplicated across the two - // tests below, which is what this helper avoids). - const renderLazyLocationMarker = () => - render( - - - , - ); - - it('fetches marker styling once the marker becomes individually visible', async () => { - renderLazyLocationMarker(); - - expect(httpService.getMarkerStyles).not.toHaveBeenCalledWith([lazyLocation.uuid]); - - await act(async () => { - jest.advanceTimersByTime(200); - await Promise.resolve(); - }); - - expect(httpService.getMarkerStyles).toHaveBeenCalledWith([lazyLocation.uuid]); - }); - - it('re-renders the marker with the lazily-fetched icon once it arrives', async () => { - renderLazyLocationMarker(); - - // Nothing matched yet - default Leaflet icon, no custom pin - expect(document.querySelector('.custom-typed-marker-icon')).not.toBeInTheDocument(); - - await act(async () => { - jest.advanceTimersByTime(200); - await Promise.resolve(); - }); - - const marker = document.querySelector('.custom-typed-marker-icon'); - expect(marker).toBeInTheDocument(); - expect(marker.innerHTML).toContain('https://cdn.example.com/parcel-locker.svg'); - }); -}); diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index e1d98a34..1b8b633b 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -1,12 +1,8 @@ import getTypedMarkerIcon from '../../src/components/MarkerPopup/getTypedMarkerIcon'; -// Mocked project-wide (see jest.config.js's moduleNameMapper) since the real -// implementation uses webpack's require.context, which plain Babel/Jest can't -// evaluate. -import resolvePhosphorIconUrl from '../../src/components/MarkerPopup/resolvePhosphorIconUrl'; // window.MARKER_STYLES is server-rendered JSON (see goodmap's map.html/db.get_marker_styles), // so fixtures are parsed from JSON strings here too - keeps the snake_case backend field -// names (icon_field, color_field, default_color) faithful to what actually arrives. +// names (default_color) faithful to what actually arrives. const setMarkerStyles = json => { globalThis.MARKER_STYLES = JSON.parse(json); }; @@ -25,41 +21,40 @@ describe('getTypedMarkerIcon', () => { expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50] })).toBeNull(); }); - it('returns null when the place value has no matching icon or color entry', () => { + it('returns null when place.marker has no matching icon or color entry', () => { setMarkerStyles(`{ - "icon_field": "pointType", - "color_field": "pointStatus", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, "colors": { "open": "#2e7d32" } }`); expect( - getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'unknownType' }), + getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { icon: 'unknownType' }, + }), ).toBeNull(); }); - it('builds a DivIcon when the icon field matches a configured type icon', () => { + it('builds a DivIcon when marker.icon matches a configured type icon', () => { setMarkerStyles(`{ - "icon_field": "pointType", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } }`); const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], - pointType: 'parcelLocker', + marker: { icon: 'parcelLocker' }, }); expect(icon).not.toBeNull(); expect(icon.options.html).toContain('https://cdn.example.com/parcel-locker.svg'); - expect(icon.options.html).toContain('background-color:black'); // fallback color, no color_field set + expect(icon.options.html).toContain('background-color:black'); // fallback color, no marker.color set expect(icon.options.iconSize).toEqual([45, 50]); }); it('masks the icon URL through CSS so it picks up the matched color, instead of embedding SVG path data', () => { setMarkerStyles(`{ - "icon_field": "pointType", - "color_field": "pointStatus", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, "colors": { "open": "#2e7d32" } }`); @@ -67,8 +62,7 @@ describe('getTypedMarkerIcon', () => { const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], - pointType: 'parcelLocker', - pointStatus: 'open', + marker: { icon: 'parcelLocker', color: 'open' }, }); // both the pin body (our own marker-pin.svg) and the type icon are CSS-masked @@ -81,43 +75,43 @@ describe('getTypedMarkerIcon', () => { expect(icon.options.html).not.toContain(' { + it('builds a DivIcon when marker.color matches a configured color, with no type icon', () => { setMarkerStyles(`{ - "color_field": "pointStatus", "colors": { "open": "#2e7d32" } }`); - const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointStatus: 'open' }); + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { color: 'open' }, + }); expect(icon).not.toBeNull(); expect(icon.options.html).toContain('#2e7d32'); }); - it('picks the color matching each value on a multi-tier color_field (e.g. speed-based coloring)', () => { + it('picks the color matching each value on a multi-tier color (e.g. speed-based coloring)', () => { setMarkerStyles(`{ - "color_field": "speedLimit", "colors": { "10": "#2e7d32", "30": "#ef6c00", "50": "#c62828" } }`); - const iconFor = speedLimit => - getTypedMarkerIcon({ uuid: '1', position: [50, 50], speedLimit }); + const iconFor = color => + getTypedMarkerIcon({ uuid: '1', position: [50, 50], marker: { color } }); expect(iconFor('10').options.html).toContain('#2e7d32'); expect(iconFor('30').options.html).toContain('#ef6c00'); expect(iconFor('50').options.html).toContain('#c62828'); }); - it('adds an asterisk badge when place.has_remark is set and a match was found', () => { + it('adds an asterisk badge when marker.badge is set and a match was found', () => { setMarkerStyles(`{ - "icon_field": "pointType", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } }`); const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], - pointType: 'parcelLocker', - has_remark: true, + marker: { icon: 'parcelLocker', badge: true }, }); expect(icon).not.toBeNull(); @@ -125,25 +119,28 @@ describe('getTypedMarkerIcon', () => { expect(icon.options.html).toContain('>*'); // asterisk badge overlay }); - it('omits the asterisk badge when place.has_remark is not set', () => { + it('omits the asterisk badge when marker.badge is not set', () => { setMarkerStyles(`{ - "icon_field": "pointType", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } }`); const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], - pointType: 'parcelLocker', + marker: { icon: 'parcelLocker' }, }); expect(icon.options.html).not.toContain('>*'); }); - it('returns our own pin in the fallback color with just the badge when has_remark is set but nothing matches', () => { + it('returns our own pin in the fallback color with just the badge when marker.badge is set but nothing matches', () => { setMarkerStyles('{}'); - const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], has_remark: true }); + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { badge: true }, + }); expect(icon).not.toBeNull(); expect(icon.options.html).toContain('background-color:black'); // fallback color @@ -151,7 +148,7 @@ describe('getTypedMarkerIcon', () => { expect(icon.options.html).not.toContain('custom-typed-marker-type-icon'); }); - it('still returns null when there is neither a match nor a remark to show', () => { + it('still returns null when there is neither a match nor a badge to show', () => { setMarkerStyles('{}'); expect(getTypedMarkerIcon({ uuid: '1', position: [50, 50] })).toBeNull(); @@ -159,7 +156,6 @@ describe('getTypedMarkerIcon', () => { it('ignores a configured default_color and uses the page fallback color instead', () => { setMarkerStyles(`{ - "icon_field": "pointType", "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, "default_color": "#123456" }`); @@ -167,7 +163,7 @@ describe('getTypedMarkerIcon', () => { const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], - pointType: 'parcelLocker', + marker: { icon: 'parcelLocker' }, }); expect(icon.options.html).not.toContain('#123456'); @@ -178,58 +174,50 @@ describe('getTypedMarkerIcon', () => { describe('getTypedMarkerIcon icon value shapes', () => { afterEach(() => { delete globalThis.MARKER_STYLES; - resolvePhosphorIconUrl.mockReset(); }); - it('resolves a {provider: "phosphor", value} entry via resolvePhosphorIconUrl', () => { - resolvePhosphorIconUrl.mockReturnValue( - '/static/phosphor-icons/shipping-container-fill.svg', - ); + it('resolves a {provider: "phosphor", value} entry to the jsdelivr CDN URL for that icon', () => { setMarkerStyles(`{ - "icon_field": "pointType", "icons": { "container": { "provider": "phosphor", "value": "shipping-container" } } }`); - const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { icon: 'container' }, + }); - expect(resolvePhosphorIconUrl).toHaveBeenCalledWith('shipping-container'); expect(icon.options.html).toContain( - 'mask-image:url(/static/phosphor-icons/shipping-container-fill.svg)', + 'mask-image:url(https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/shipping-container-fill.svg)', ); }); it('resolves a {provider: "url", value} entry as a plain URL, without touching phosphor', () => { setMarkerStyles(`{ - "icon_field": "pointType", "icons": { "container": { "provider": "url", "value": "https://cdn.example.com/c.svg" } } }`); - const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { icon: 'container' }, + }); - expect(resolvePhosphorIconUrl).not.toHaveBeenCalled(); expect(icon.options.html).toContain('mask-image:url(https://cdn.example.com/c.svg)'); + expect(icon.options.html).not.toContain('jsdelivr'); }); it('still accepts a plain string entry as a direct URL, unchanged from before', () => { setMarkerStyles(`{ - "icon_field": "pointType", "icons": { "container": "https://cdn.example.com/c.svg" } }`); - const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); + const icon = getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { icon: 'container' }, + }); expect(icon.options.html).toContain('mask-image:url(https://cdn.example.com/c.svg)'); }); - - it('treats an unresolvable phosphor icon name as no icon, not a broken URL', () => { - resolvePhosphorIconUrl.mockReturnValue(''); - setMarkerStyles(`{ - "icon_field": "pointType", - "icons": { "container": { "provider": "phosphor", "value": "not-a-real-icon" } } - }`); - - const icon = getTypedMarkerIcon({ uuid: '1', position: [50, 50], pointType: 'container' }); - - expect(icon).toBeNull(); - }); }); diff --git a/frontend/tests/MarkerPopup/requestMarkerStyle.test.js b/frontend/tests/MarkerPopup/requestMarkerStyle.test.js deleted file mode 100644 index 7f2abebf..00000000 --- a/frontend/tests/MarkerPopup/requestMarkerStyle.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import requestMarkerStyle from '../../src/components/MarkerPopup/requestMarkerStyle'; -import httpService from '../../src/services/http/httpService'; -import useMarkerStylesStore from '../../src/components/Map/store/markerStyles.store'; - -jest.mock('../../src/services/http/httpService'); - -describe('requestMarkerStyle', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - useMarkerStylesStore.setState({ stylesByUuid: {} }); - globalThis.MARKER_STYLES = { icon_field: 'pointType' }; // eslint-disable-line camelcase -- matches backend API schema property name - delete globalThis.FEATURE_FLAGS; - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - delete globalThis.MARKER_STYLES; - delete globalThis.FEATURE_FLAGS; - }); - - it('still fetches when marker styling is not configured, for has_remark', () => { - globalThis.MARKER_STYLES = {}; - httpService.getMarkerStyles.mockResolvedValue({ 'uuid-1': { has_remark: true } }); // eslint-disable-line camelcase -- matches backend API schema property name - requestMarkerStyle('uuid-1'); - jest.runAllTimers(); - expect(httpService.getMarkerStyles).toHaveBeenCalledWith(['uuid-1']); - }); - - it('does nothing when server-side clustering is enabled', () => { - globalThis.FEATURE_FLAGS = { USE_SERVER_SIDE_CLUSTERING: true }; - requestMarkerStyle('uuid-1'); - jest.runAllTimers(); - expect(httpService.getMarkerStyles).not.toHaveBeenCalled(); - }); - - it('batches uuids requested within the debounce window into one request', async () => { - httpService.getMarkerStyles.mockResolvedValue({ 'uuid-1': { pointType: 'a' } }); - requestMarkerStyle('uuid-1'); - requestMarkerStyle('uuid-2'); - jest.runAllTimers(); - await Promise.resolve(); - expect(httpService.getMarkerStyles).toHaveBeenCalledTimes(1); - expect(httpService.getMarkerStyles).toHaveBeenCalledWith(['uuid-1', 'uuid-2']); - }); - - it('merges results into the store, defaulting unmatched uuids to {}', async () => { - httpService.getMarkerStyles.mockResolvedValue({ 'uuid-1': { pointType: 'a' } }); - requestMarkerStyle('uuid-1'); - requestMarkerStyle('uuid-2'); - jest.runAllTimers(); - await Promise.resolve(); - expect(useMarkerStylesStore.getState().stylesByUuid).toEqual({ - 'uuid-1': { pointType: 'a' }, - 'uuid-2': {}, - }); - }); - - it('does not re-request a uuid already known, even with no matching styling', () => { - useMarkerStylesStore.setState({ stylesByUuid: { 'uuid-1': {} } }); - requestMarkerStyle('uuid-1'); - jest.runAllTimers(); - expect(httpService.getMarkerStyles).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 55bc09ee..1d328354 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -21,6 +21,14 @@ module.exports = (env, argv) => { ], cache: { type: 'filesystem', + // webpack-dev-server (serve:local/serve:prod/serve:network, all pass + // --env serve=...) injects HMR machinery into its build even under + // --mode production - sharing a cache namespace with the plain `build` + // script (same mode, no --env) corrupts it: a later plain build can hit + // dev-server-only constructs (e.g. HarmonyAcceptDependency) it doesn't + // know how to handle, crashing with "Invalid value used as weak map + // key". Keying the cache name off env.serve keeps the two fully apart. + name: env && env.serve ? 'dev-server' : 'build', cacheDirectory: path.resolve(__dirname, '.webpack-cache'), buildDependencies: { config: [__filename], @@ -54,27 +62,12 @@ module.exports = (env, argv) => { }, { test: /\.(jpe?g|png|gif|woff|woff2|eot|ttf|svg)$/i, - exclude: /@phosphor-icons\/core/, loader: 'url-loader', options: { limit: 8192, name: '[path][name].[ext]', }, }, - { - // @phosphor-icons/core's full icon set is pulled in via - // require.context (see resolvePhosphorIconUrl.js) so any icon - // can be referenced by name from deployment config, without a - // frontend code change - never inline these (limit: 0), or that - // whole set would bloat the main JS bundle instead of staying as - // separate files fetched only for icons actually rendered. - test: /@phosphor-icons\/core.*\.svg$/i, - loader: 'url-loader', - options: { - limit: 0, - name: 'phosphor-icons/[name].[ext]', - }, - }, ], }, devServer: { diff --git a/goodmap/api/api_models.py b/goodmap/api/api_models.py index eb5806ca..fa111072 100644 --- a/goodmap/api/api_models.py +++ b/goodmap/api/api_models.py @@ -5,7 +5,7 @@ and request/response validation. """ -from typing import Any, Literal +from typing import Any, Literal, NamedTuple from pydantic import BaseModel, Field, RootModel @@ -68,45 +68,67 @@ class SuccessResponse(BaseModel): message: str = Field(..., description="Success message") -class LocationBasicInfo(BaseModel): - """One point as returned by the list endpoint: identity and position only.""" +class PinMarkerFields(NamedTuple): + """Which of this deployment's fields drive pin icon/color, threaded through to + marker_style_values() by name, keeping the two roles distinct (a frozenset of + both would lose which is which).""" - uuid: str = Field(..., description="Location UUID") - position: tuple[Latitude, Longitude] = Field(..., description=_POSITION_DESCRIPTION) + icon_field: str | None = None + color_field: str | None = None -class LocationList(RootModel[list[LocationBasicInfo]]): - """List of points, each with identity and position only.""" +class MarkerInfo(BaseModel): + """Pin styling for one point: keys into the deployment's MARKER_STYLES lookup + tables, not resolved server-side to a URL/hex value.""" + + icon: str | int | float | bool | None = Field( + None, description="Raw icon_field value; key into MARKER_STYLES.icons" + ) + color: str | int | float | bool | None = Field( + None, description="Raw color_field value; key into MARKER_STYLES.colors" + ) + badge: bool | None = Field( + None, description="Present and true only when the point has a remark" + ) -class LocationMarkerStyles(RootModel[dict[str, dict[str, Any]]]): - """Map of uuid -> pin styling data: has_remark (drives the asterisk badge) plus - whatever marker_styles.icon_field/color_field point at (drive icon/color) - for - lazily fetching it once a marker becomes individually visible instead of getting - it upfront for every location. Unknown/missing uuids are simply absent from the - response, not an error.""" +class LocationBasicInfo(BaseModel): + """One point as returned by the list endpoint: identity, position, and pin styling.""" + uuid: str = Field(..., description="Location UUID") + position: tuple[Latitude, Longitude] = Field(..., description=_POSITION_DESCRIPTION) + marker: MarkerInfo | None = Field( + None, + description="Pin styling for this point; absent when nothing applies " + "(no icon/color match and no remark)", + ) -class MarkerStylesQueryParams(BaseModel): - """Query parameters of the marker styles lazy-loading endpoint.""" - uuid: list[str] = Field(default_factory=list, description="Location UUIDs to fetch styling for") +class LocationList(RootModel[list[LocationBasicInfo]]): + """List of points, each with identity, position, and pin styling.""" -def marker_style_values(location: BaseModel, style_fields: frozenset[str]) -> dict[str, Any]: - """Pin styling data for `location`, as /api/locations/marker-styles returns it. +def marker_style_values(location: BaseModel, pin_marker_fields: PinMarkerFields) -> dict[str, Any]: + """Pin styling data for `location`, as /api/locations includes it for every point. - Always includes has_remark (drives the asterisk badge), plus the value of any - of `style_fields` this location actually has (drive icon/color). This is API - response shaping, not something the location domain model needs to know how to - do itself - it belongs alongside the models it fills, not on LocationBase. + Returns {"marker": {...}} with icon/color (this location's value of the field + pin_marker_fields names, if it has one) and badge (present and true only when the + point has a remark), or {} when none of those apply. This is API response shaping, + not something the location domain model needs to know how to do itself - it + belongs alongside the models it fills, not on LocationBase. """ - data: dict[str, Any] = {"has_remark": bool(getattr(location, "remark", None))} - for field in sorted(style_fields): - value = getattr(location, field, None) + marker: dict[str, Any] = {} + if pin_marker_fields.icon_field is not None: + value = getattr(location, pin_marker_fields.icon_field, None) if value is not None: - data[field] = value - return data + marker["icon"] = value + if pin_marker_fields.color_field is not None: + value = getattr(location, pin_marker_fields.color_field, None) + if value is not None: + marker["color"] = value + if getattr(location, "remark", None): + marker["badge"] = True + return {"marker": marker} if marker else {} class ClusterInfo(BaseModel): @@ -122,6 +144,9 @@ class ClusterInfo(BaseModel): cluster_count: int | None = Field( None, description="Number of points the cluster stands for; null for a point" ) + marker: MarkerInfo | None = Field( + None, description="Pin styling for a point; null for a cluster or unstyled point" + ) class ClusterList(RootModel[list[ClusterInfo]]): diff --git a/goodmap/api/core_api.py b/goodmap/api/core_api.py index ac4285c1..40965f9b 100644 --- a/goodmap/api/core_api.py +++ b/goodmap/api/core_api.py @@ -25,12 +25,11 @@ LanguagesResponse, LocationDetail, LocationList, - LocationMarkerStyles, LocationQueryParams, LocationReportRequest, LocationReportResponse, LocationSchemaResponse, - MarkerStylesQueryParams, + PinMarkerFields, SuccessResponse, VersionResponse, marker_style_values, @@ -121,20 +120,27 @@ def make_tuple_translation(keys_to_translate): return [(x, gettext(x)) for x in keys_to_translate] -def get_locations_from_request(database, request_args): +def get_locations_from_request(database, request_args, pin_marker_fields): """ Shared helper to fetch locations from database based on request arguments. Args: database: Database instance request_args: Request arguments (flask.request.args) + pin_marker_fields: This deployment's marker_styles icon_field/color_field + names - merged into each location's basic_info as a nested `marker` + object so the frontend can style pins without a further per-location + request. Returns: - List of locations as basic_info dicts + List of locations as basic_info dicts, each merged with marker_style_values. """ query_params = request_args.to_dict(flat=False) all_locations = database.get_locations(query_params) - return [x.basic_info() for x in all_locations] + return [ + {**location.basic_info(), **marker_style_values(location, pin_marker_fields)} + for location in all_locations + ] def photo_attachment_from_request(photo_attachment_config: AttachmentConfig): @@ -198,7 +204,7 @@ def core_pages( photo_attachment_config: AttachmentConfig, feature_flags: FeatureFlagSet, shortcodes: dict[str, Shortcode], - pin_marker_fields: frozenset[str] = frozenset(), + pin_marker_fields: PinMarkerFields = PinMarkerFields(), ) -> Blueprint: core_api_blueprint = Blueprint("api", __name__, url_prefix="/api") @@ -345,11 +351,11 @@ def report_location(): def get_locations(): """Get list of locations with basic info. - Returns locations filtered by query parameters, showing only uuid and - position. Pin styling (has_remark, marker_styles field values) is fetched - separately, per-uuid, via /api/locations/marker-styles. + Returns locations filtered by query parameters: uuid, position, and a + `marker` object (icon/color/badge) with everything needed to render a + styled pin. """ - locations = get_locations_from_request(database, request.args) + locations = get_locations_from_request(database, request.args, pin_marker_fields) return jsonify(locations) @core_api_blueprint.route("/locations-clustered", methods=["GET"]) @@ -368,7 +374,7 @@ def get_locations_clustered(): query_params = request.args.to_dict(flat=False) zoom = int(query_params.get("zoom", [7])[0]) - points = get_locations_from_request(database, request.args) + points = get_locations_from_request(database, request.args, pin_marker_fields) if not points: return jsonify([]) @@ -423,30 +429,6 @@ def get_location(location_id): formatted_data = prepare_pin(location.model_dump(), visible_data, meta_data, shortcodes) return jsonify(formatted_data) - @core_api_blueprint.route("/locations/marker-styles", methods=["GET"]) - @spec.validate( - tags=[TAG_MAP_DATA], - query=MarkerStylesQueryParams, - resp=Response(HTTP_200=LocationMarkerStyles), - ) - def get_locations_marker_styles(): - """Get pin styling data for specific locations, by uuid. - - For lazily fetching has_remark and marker_styles-relevant field values - only once a client-side-clustered marker becomes individually visible, - instead of the frontend getting them upfront for every location (see - lazy-load-marker-styling-plan.md). Unknown or missing uuids are - silently omitted from the response rather than erroring the whole - request - a marker that's re-clustered mid-flight isn't a client bug. - """ - result: dict[str, dict[str, Any]] = {} - for location_uuid in request.args.getlist("uuid"): - location = database.get_location(location_uuid) - if location is None: - continue - result[location_uuid] = marker_style_values(location, pin_marker_fields) - return jsonify(result) - @core_api_blueprint.route("/version", methods=["GET"]) @spec.validate(tags=[TAG_META], resp=Response(HTTP_200=VersionResponse)) def get_version(): diff --git a/goodmap/clustering.py b/goodmap/clustering.py index aa048236..fff42eee 100644 --- a/goodmap/clustering.py +++ b/goodmap/clustering.py @@ -25,11 +25,11 @@ def map_clustering_data_to_proper_lazy_loading_object(input_array): Args: input_array: List of cluster dicts with 'count', 'longitude', 'latitude', - and 'uuid' keys. + 'uuid' and 'marker' keys. Returns: List of response dicts with 'position', 'uuid', 'cluster_uuid', - 'cluster_count', and 'type' keys. + 'cluster_count', 'type' and 'marker' keys. """ response_array = [] for item in input_array: @@ -40,6 +40,7 @@ def map_clustering_data_to_proper_lazy_loading_object(input_array): "cluster_uuid": None, "cluster_count": None, "type": "point", + "marker": item.get("marker"), } response_array.append(response_object) continue @@ -61,17 +62,19 @@ def match_clusters_uuids(points, clusters): Match single-point clusters to their original point UUIDs. For clusters containing exactly one point, this function attempts to match the cluster - coordinates back to the original point to retrieve its UUID. The 'uuid' key is optional - and will only be present in single-point clusters where a matching point is found. + coordinates back to the original point to retrieve its UUID and marker styling. The + 'uuid'/'marker' keys are optional and will only be present in single-point clusters + where a matching point is found. Args: - points: List of point dicts with 'position' and 'uuid' keys + points: List of point dicts with 'position', 'uuid' and 'marker' keys clusters: List of cluster dicts with 'longitude', 'latitude', and 'count' keys. - For single-point clusters (count=1), a 'uuid' key will be added if a - matching point is found (modified in place) + For single-point clusters (count=1), 'uuid' and 'marker' keys will be + added, from the matching point if found or None otherwise (modified + in place) Returns: - The modified clusters list with 'uuid' keys added to matched single-point clusters + The modified clusters list with 'uuid'/'marker' keys added to single-point clusters """ points_coords = [(point["position"][0], point["position"][1]) for point in points] tree = KDTree(points_coords) @@ -82,6 +85,7 @@ def match_clusters_uuids(points, clusters): if dist < DISTANCE_THRESHOLD: closest_point = points[idx] cluster["uuid"] = closest_point["uuid"] + cluster["marker"] = closest_point.get("marker") else: # Log warning when no match is found - indicates data inconsistency logger.warning( @@ -93,4 +97,5 @@ def match_clusters_uuids(points, clusters): DISTANCE_THRESHOLD, ) cluster["uuid"] = None + cluster["marker"] = None return clusters diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 9bca9f96..1e733384 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -7,6 +7,7 @@ from typing import Any from flask import Blueprint, jsonify, redirect, render_template, session +from flask_compress import Compress from flask_wtf.csrf import CSRFError from platzky import platzky from platzky.config import languages_dict @@ -15,6 +16,7 @@ from platzky.shortcodes import Shortcode from goodmap.api.admin_api import admin_pages +from goodmap.api.api_models import PinMarkerFields from goodmap.api.core_api import core_pages from goodmap.config import GoodmapConfig from goodmap.data_models.location import create_location_model @@ -150,6 +152,12 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: extra_plugins_entrypoints=[_PLUGIN_ENTRY_POINT_GROUP], ) + # Compress JSON/text responses (gzip, or brotli/zstd if the client offers them). + # Not every deployment sits behind a proxy that already does this, and it's a + # sizeable win for /api/locations, whose repeated marker-styling field values + # compress especially well. + Compress(app) + frontend_static_dir = os.path.join(directory, "static", "frontend") app.register_blueprint( Blueprint( @@ -175,11 +183,6 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: location_obligatory_fields = get_location_obligatory_fields(app.db) categories = get_category_data(app.db)(app.db)["categories"] marker_styles = get_marker_styles(app.db)(app.db) - marker_style_fields = { - field - for field in (marker_styles.get("icon_field"), marker_styles.get("color_field")) - if field is not None - } location_model = create_location_model(location_obligatory_fields, categories) app.db = extend_db_with_goodmap_queries(app.db, location_model) @@ -188,8 +191,21 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: # pin_marker_fields is app-wiring knowledge - which of this deployment's fields # marker_styles.icon_field/color_field actually point at - not something the # location model itself needs to know; threaded to core_pages() for - # goodmap.api.api_models.marker_style_values() to use. - pin_marker_fields = frozenset(marker_style_fields) & obligatory_field_names + # goodmap.api.api_models.marker_style_values() to use. A configured field that + # isn't actually an obligatory field of this deployment's locations is dropped + # rather than trusted blindly. + pin_marker_fields = PinMarkerFields( + icon_field=( + marker_styles.get("icon_field") + if marker_styles.get("icon_field") in obligatory_field_names + else None + ), + color_field=( + marker_styles.get("color_field") + if marker_styles.get("color_field") in obligatory_field_names + else None + ), + ) app.extensions["goodmap"] = {"location_obligatory_fields": location_obligatory_fields} diff --git a/poetry.lock b/poetry.lock index ace2010b..47bfa699 100644 --- a/poetry.lock +++ b/poetry.lock @@ -277,6 +277,103 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "backports-zstd" +version = "1.7.0" +description = "Backport of compression.zstd" +optional = false +python-versions = "<3.14,>=3.10" +groups = ["main"] +markers = "python_version < \"3.14\"" +files = [ + {file = "backports_zstd-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c4f557bd8579d38316344c205b2a540e84b1014fb3721205eb6c3eb5322e9d9"}, + {file = "backports_zstd-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:68ee21f0efa3f06d3d3cbb5f291c177497fc550ebef732b0a38599de8db1ee32"}, + {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:beb8d6cf5ab3c27cca3a5fdcfeeb228885083d606f0709ffc0a698aabc4f13ee"}, + {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f1ecac082932870df519818e88eb938a03573245f629e34979141583112123"}, + {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0469fbe83c85f5a1fb83657242477ed612d4d4d3c000b67f8a67bc839115b09"}, + {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f53a23a40d25236aab6e0e817f2cbbf27e6f8fe976fedaa6b9ee53fc809b9"}, + {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b7929cbaff68c124d2366d803dc654347f37637a3df73a2a0a8f2dbee4819cc"}, + {file = "backports_zstd-1.7.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4108fa7b126fb4b08853670bb32c4a812aab355b8264aa1a27b7bb724ae6ce0"}, + {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1a868cff3de171b4961acd9fcf9e843cc966783aa0b2bdfdba876ec20023e24f"}, + {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e833fb85673c0a8c880dc3f759c87726680f953492e9275f666fcbfd127c6e8e"}, + {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3c32951fe1ae22f6f059d3c02cfdc21155cee83be456c424d955bf493ba2a9dc"}, + {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d312ff5018199e1f889ca470a98361feaf2d194f82091cbbd366bb539e7c3583"}, + {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a43e03d7769a06b5ccf4cad5fcf4b3e690b1b36476632d3e1bc923e12579963f"}, + {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff08fbdf4090c8075bcc0f3ffccf3098e4fd6a0d9a4c5c2078398ea5bb2ddd1f"}, + {file = "backports_zstd-1.7.0-cp310-cp310-win32.whl", hash = "sha256:e9e7bf426a21772b3ac1fe5c967678063d7bfcb58d2f559b98bf4c9c6c52f95f"}, + {file = "backports_zstd-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:02c4458f25f884131c59d54549a3bdfd649ca3384f1dd15204762171d9e24739"}, + {file = "backports_zstd-1.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:db2fb308ca3669e2913e66aae9173d9a9d5c448caaa2f1bdd12efbfe480f0fde"}, + {file = "backports_zstd-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:165a8898c5514b69533edf4ab1f4f4b4bacc62a137a76f36889b473150ec28a5"}, + {file = "backports_zstd-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:700ebb797956767679dbca38e45eaa5c21630e460e31ef53bb4b849125bb5d87"}, + {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:47f14a24428a2bc070e26c402f8d6d25676345c32fa116b16b60167a2925df2a"}, + {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c358e72e5ff8f23e9f3ec778be4d67ddc5ced3e6d8f03521db29d7357a773fc3"}, + {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8c183027eae38f5b0643d153f7f91e569d22ee8db25639aea0745677a38ed8"}, + {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d8493f71d9c5c05d18554afc6bb9a319a6674478e8f3042c7e22900c3a06f4d"}, + {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2e505d8923e1e9224cf249b99c92cf728e9eb91fbd1e07a9c2816013621fad3"}, + {file = "backports_zstd-1.7.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d1bdc293267200e31baf35aa142c6d0ac3e8cce650f79c84e6a32980dfbfd5c"}, + {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d85c18170e8cdba339edc67a5021cf79ccc858f5fda6aeae71f9015c5e463f6"}, + {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:96a6f8d3f4cefb6b11ebfc30fc0d970430ecfb169a6555990734a2a46977ec4b"}, + {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2c01cb823ed1b2422905a9791759bdc986e44e7a12b4661e9d712d5c8946016"}, + {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:86785aef2b4663a97c932d829ddc9565354cc628e2ae61764d9d93c8b186d65f"}, + {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:633ceee3ba86f696fc4e992f7bce558c132c26d04d64d0bb8c2f5d487d5b3aee"}, + {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a80bc6a8c9aeaad76cc3ecd58067ec038a764807186b0df970c760df39b89c7"}, + {file = "backports_zstd-1.7.0-cp311-cp311-win32.whl", hash = "sha256:1713271e2faea852a1682a6143c19c3506cd2e1b71f60a8924c59a9d2554d2b2"}, + {file = "backports_zstd-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae840be71108f6020567dd389c973e70a4374a6c0b03c02d3242c8a98a9b3cdb"}, + {file = "backports_zstd-1.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:8827a5601c749a986faa163f3b59d59eedc5947812be114f7132c3d4ad153fee"}, + {file = "backports_zstd-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5548a857bb0fcc5449cc3687353547396c6b1ecd4bd882f1cd34fa8d29e70ca"}, + {file = "backports_zstd-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bab192b934fdf5a03df4752556d9c8af2d058163fdfbafd4a253cdfe25449a6f"}, + {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:8344260bed9842c415a93d9bfe23ea834e5f27758827d56933d8c0d06db507a2"}, + {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c55e55e1e9dee312bc5e186386e6aa5207482a6d2242bd7c14709ded254f87f"}, + {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cf609af3735c7e697ccd13f6b0c88da57c201b6ea63c6afbfe81d6f9b50e298c"}, + {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:676a37971f676830d4f90cee8fdf4e438781596fb2f2d1984ac76c9b3eb39a69"}, + {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:470895d0bcddc850766e593d1b26764fb138c2feed149f515a2627ef9587d54c"}, + {file = "backports_zstd-1.7.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02f2f6649a342d0901ddb35596ddadb7c3bb1cf6bb54d691e5e0285f1fa0674f"}, + {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:132ba81fad59d44958b7d10da31545e7128c469cfbc2e268d0eaab96daa64175"}, + {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a3e1c6ce0b232ee6703ed24ee126e8186107f5a4e56edbd21cd1aa5a8c6bfd12"}, + {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d7a7cb964eb8d1bb5d039970b16fe54802ea47dc935ae96d9874844a126bf8ff"}, + {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:12a9842a2ec2854cbec7f252ab29d44c2b772788a9bbafded743ca4bf73b115f"}, + {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:138154eea8ced84394991bf0e819dba6b690306a178dd528c28eee724b7d4aec"}, + {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:468b636ed365627b364c94be1c35a52858e13b5bc1fa3f068bbc71b1af65f3d7"}, + {file = "backports_zstd-1.7.0-cp312-cp312-win32.whl", hash = "sha256:f026fe2e89b7ff01ba6ebec6abaff34c6063919151a32afb68714cf139e17c50"}, + {file = "backports_zstd-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:2ea62ba2f1a6e6c9e6dc108921f9ae881969ca72e073162fa488d0de3eb2713f"}, + {file = "backports_zstd-1.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:cefb983345c55ccaa20423a4eb96434730e6d640ffa2db9b60e5bedb0fbef94e"}, + {file = "backports_zstd-1.7.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:a3fbcbf819bee2b06b8666b13742098d0f40663ee34e64a12bc360ec0f5e3d89"}, + {file = "backports_zstd-1.7.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:efee02f18e04c2e9e6d694c5cf9b7457c4bda3ea96f48b1ee69769e06bb9d89f"}, + {file = "backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ecc95fa0e91d92951d74468e7789afdf91d9e702f40af2d0fcbf0ded4d0f650a"}, + {file = "backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34154d82fc0246738159084d146401073f9ac9cfd755b66bb8853ca06037810c"}, + {file = "backports_zstd-1.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44b687b1c0be5cb279693d2682f91ff84c559d679b2ef2fbe501fe4b2db2c4bb"}, + {file = "backports_zstd-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dcdbd368659f46b570114eeea36b75347716523870d71f6bc5d7801862aefd6e"}, + {file = "backports_zstd-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eda97fa535d4651a4ccdeed4ee7dde3978369046abc8a7456a7117d4271f9333"}, + {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7e3999b5141d7f85171822d06112f70f7f317d162f0120530dd2c7a28dbf8add"}, + {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69367726f4075c2574746f5883b0dc045805c5b02a81fdf8c829c26d33969de3"}, + {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15e97edfd173ade365c01bac7d9d297fa906686015cdbcb5f32a0d410887826b"}, + {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:32a94cdcf16b44395cd55086ea38877395ca6bf3362cb507b0eb86db2a45a6a4"}, + {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4887a8a1fd1290017fe5a1d29a7d1dc5c57f9477fbd64f119316a7e3ae769"}, + {file = "backports_zstd-1.7.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e590313ce156f1d8986dff3107e8ed1651d6d106a56b3a95f965ff8d845ba979"}, + {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:565270b0d6497970fa97a0df59593ae0d225e4176678bbce851d39e5f8aa422b"}, + {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:37ef23c6c522fe935726c8fba6344350973c4a23b06d10194d90d0868b09ff7a"}, + {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b3975330159f1efdd1fba76afe1c7b84f66f26e2bf209b32630fb148d647e0d5"}, + {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b40bc8cd0a86cbbe8263a9c3a2bf2e34897483516c6d799725412a19524c32e3"}, + {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f37e12ef10747f76901b1f20ef70d33221e861de177dba5ba08552242c6fd4bd"}, + {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5992143b2a8b71b4d17afed20cce2df50f8718228e31d6e716493b1fe9201712"}, + {file = "backports_zstd-1.7.0-cp313-cp313-win32.whl", hash = "sha256:31ae30d216ffae9243dfa607bcb995f94a70de5765bb8fae1e35ea1ad6497959"}, + {file = "backports_zstd-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:8086b4a7443bb2863f7ef8edb317b715d5f3ccec6c5512619bd23d57661ba1b7"}, + {file = "backports_zstd-1.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:7eaceeec75e1dbdce40b81fb0ed1ffdb7ce492d970db7f8aabd6a95ccd6c3dd3"}, + {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8d59b145d379745c4461adbe9a9afc647f90ca164f50ea2566c08d6601531d1c"}, + {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d3c3cda113aacabe7fd0594ad2832b7023a5fee84009406fe4d230906d80fb25"}, + {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f8da4758af21788a9a90f56b7f658a35d33034e55d416fd40e8bcfbb347b90c2"}, + {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e27916c92272ab4285d8d2e02eebe5f4198da10d82250b6edfa3ce372aff6f79"}, + {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:76e205599a60acc0824bf03522fb9ad25449492535e1efba18f047e2ce48e745"}, + {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2feaefcca77c6ac97a46a64f9d41c429caa135a837c54b46398022716abd8184"}, + {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:de58be0a3109cfb83b4e61e2b6eb770201cc132ee5a7c677cd8e0140ad2be80c"}, + {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c13f73d0389cdc88b02c05e8175d8ad3030e9e70ee079748763166aa843b647d"}, + {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:a2e30ea49c673533d40eb73d0f7abc0ebe9d2e4fc6dbada5ad60b42ff98ffa86"}, + {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e3f760ee9d16378e3cde9d862e1c9ced577a86736763fb486b9f731d5116807"}, + {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25caf23dc36de3b839d16c25893751323cf51a8c986f2d01478c16b25133e2e8"}, + {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a64e796c7eee69dfe45827b2e003b7731785ec890c73ea5f5fbc30a1c362fcad"}, + {file = "backports_zstd-1.7.0.tar.gz", hash = "sha256:1a967189c1822b6e85a2e550fdfc88a3272c17633ea0a4732dac5911a8034f2b"}, +] + [[package]] name = "black" version = "26.3.1" @@ -360,6 +457,149 @@ docs = ["Sphinx (>=3.3.1)", "doc8 (>=0.8.1)", "sphinx-rtd-theme (>=0.5.0)", "sph linting = ["black", "isort", "pycodestyle"] testing = ["pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)"] +[[package]] +name = "brotli" +version = "1.2.0" +description = "Python bindings for the Brotli compression library" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "brotli-1.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92"}, + {file = "brotli-1.2.0-cp27-cp27m-win32.whl", hash = "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb"}, + {file = "brotli-1.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1"}, + {file = "brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997"}, + {file = "brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae"}, + {file = "brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03"}, + {file = "brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036"}, + {file = "brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161"}, + {file = "brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5"}, + {file = "brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a"}, + {file = "brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888"}, + {file = "brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d"}, + {file = "brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3"}, + {file = "brotli-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533"}, + {file = "brotli-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96"}, + {file = "brotli-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13"}, + {file = "brotli-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a"}, + {file = "brotli-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982"}, + {file = "brotli-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7"}, + {file = "brotli-1.2.0-cp38-cp38-win32.whl", hash = "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c"}, + {file = "brotli-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4"}, + {file = "brotli-1.2.0-cp39-cp39-win32.whl", hash = "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49"}, + {file = "brotli-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937"}, + {file = "brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a"}, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.1" +description = "Python CFFI bindings to the Brotli library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation == \"PyPy\"" +files = [ + {file = "brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd"}, + {file = "brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5"}, + {file = "brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac"}, + {file = "brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec"}, + {file = "brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000"}, + {file = "brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4"}, + {file = "brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce"}, + {file = "brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a"}, + {file = "brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187"}, + {file = "brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede"}, + {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851"}, + {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf"}, + {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4"}, + {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1"}, + {file = "brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c"}, +] + +[package.dependencies] +cffi = [ + {version = ">=1.17.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.0.0", markers = "python_version < \"3.13\""}, +] + [[package]] name = "cachecontrol" version = "0.14.4" @@ -413,7 +653,6 @@ description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -961,6 +1200,24 @@ Flask = ">=2.0" Jinja2 = ">=3.1" pytz = ">=2022.7" +[[package]] +name = "flask-compress" +version = "1.24" +description = "Compress responses in your Flask app with gzip, deflate, brotli or zstandard." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c"}, + {file = "flask_compress-1.24.tar.gz", hash = "sha256:14097cefe59ecb3e466d52a6aeb62f34f125a9f7dadf1f33a53e430ce4a50f31"}, +] + +[package.dependencies] +"backports.zstd" = {version = "*", markers = "python_version < \"3.14\""} +brotli = {version = "*", markers = "platform_python_implementation != \"PyPy\""} +brotlicffi = {version = "*", markers = "platform_python_implementation == \"PyPy\""} +flask = "*" + [[package]] name = "flask-minify" version = "0.50" @@ -2432,7 +2689,7 @@ description = "C parser in Python" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +markers = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, @@ -3988,4 +4245,4 @@ docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "f20b77cfa8f9e22f65d2a36a61a3c385a71ea8ee8a31e879b5ee286bbc5e133f" +content-hash = "6ff5abb355a448795fa4104f632c63dd0378dc8690117a6b7e402d57b0dbc63d" diff --git a/pyproject.toml b/pyproject.toml index 3f412599..3af8a031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ scipy = "^1.15.1" sphinx = {version = "^8.0.0", optional = true} sphinx-rtd-theme = {version = "^3.0.0", optional = true} myst-parser = {version = "^4.0.0", optional = true} +flask-compress = "^1.24" [tool.poetry.extras] docs = [ diff --git a/tests/unit_tests/data_models/test_location.py b/tests/unit_tests/data_models/test_location.py index adbbf8db..4ae1c104 100644 --- a/tests/unit_tests/data_models/test_location.py +++ b/tests/unit_tests/data_models/test_location.py @@ -133,9 +133,9 @@ def test_category_validation_rejects_invalid_list_item(): def test_basic_info_is_identity_and_position_only(): """basic_info() carries uuid/position only, even for a category field a deployment's marker_styles config might reference and even when the location - has a remark - both has_remark and marker styling values are fetched - separately (see goodmap.api.api_models.marker_style_values and - lazy-load-marker-styling-plan.md), only once a marker is actually visible.""" + has a remark - the marker object (icon/color/badge) is shaped separately + (see goodmap.api.api_models.marker_style_values), merged in alongside + basic_info() by the API layer rather than known to the domain model itself.""" location_model = create_location_model( obligatory_fields=[("type_of_place", "str"), ("name", "str")], categories={"type_of_place": ["parcel_locker", "container"]}, diff --git a/tests/unit_tests/test_api_models.py b/tests/unit_tests/test_api_models.py index 2f5af344..f5f62c7c 100644 --- a/tests/unit_tests/test_api_models.py +++ b/tests/unit_tests/test_api_models.py @@ -1,13 +1,12 @@ from typing import cast -from goodmap.api.api_models import marker_style_values +from goodmap.api.api_models import PinMarkerFields, marker_style_values from goodmap.data_models.location import LocationBase, create_location_model -def test_marker_style_values_includes_has_remark_and_configured_field_values(): - """marker_style_values() always includes has_remark (drives the asterisk - badge), plus the requested style_fields' values (drive icon/color) off the - given location.""" +def test_marker_style_values_includes_badge_and_configured_field_values(): + """marker_style_values() includes badge when true (drives the asterisk + badge), plus the icon/color field values off the given location.""" location_model = create_location_model( obligatory_fields=[("type_of_place", "str"), ("name", "str")], categories={"type_of_place": ["parcel_locker", "container"]}, @@ -20,24 +19,42 @@ def test_marker_style_values_includes_has_remark_and_configured_field_values(): remark="a remark", ) location = cast(LocationBase, location) - assert marker_style_values(location, frozenset({"type_of_place"})) == { - "has_remark": True, - "type_of_place": "parcel_locker", + assert marker_style_values(location, PinMarkerFields(icon_field="type_of_place")) == { + "marker": {"icon": "parcel_locker", "badge": True}, } -def test_marker_style_values_has_remark_false_and_empty_when_no_style_fields(): +def test_marker_style_values_sets_icon_and_color_independently(): + location_model = create_location_model( + obligatory_fields=[("type_of_place", "str"), ("transparency", "str"), ("name", "str")], + categories={"type_of_place": ["parcel_locker"], "transparency": ["lacking"]}, + ) + location = location_model( + uuid="1", + name="test", + type_of_place="parcel_locker", + transparency="lacking", + position=(50, 50), + ) + location = cast(LocationBase, location) + fields = PinMarkerFields(icon_field="type_of_place", color_field="transparency") + assert marker_style_values(location, fields) == { + "marker": {"icon": "parcel_locker", "color": "lacking"}, + } + + +def test_marker_style_values_omits_marker_when_no_remark_and_no_style_fields(): location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) location = location_model(uuid="1", name="test", position=(50, 50)) location = cast(LocationBase, location) - assert marker_style_values(location, frozenset()) == {"has_remark": False} + assert marker_style_values(location, PinMarkerFields()) == {} -def test_marker_style_values_ignores_style_fields_the_location_does_not_have(): +def test_marker_style_values_ignores_style_field_the_location_does_not_have(): """A style field that isn't actually one of this location's attributes (e.g. misconfigured marker_styles, or narrowed away upstream) is simply skipped, not an error.""" location_model = create_location_model(obligatory_fields=[("name", "str")], categories={}) location = location_model(uuid="1", name="test", position=(50, 50)) location = cast(LocationBase, location) - assert marker_style_values(location, frozenset({"nonexistent_field"})) == {"has_remark": False} + assert marker_style_values(location, PinMarkerFields(icon_field="nonexistent_field")) == {} diff --git a/tests/unit_tests/test_clustering.py b/tests/unit_tests/test_clustering.py index 6b32c127..64bdbf15 100644 --- a/tests/unit_tests/test_clustering.py +++ b/tests/unit_tests/test_clustering.py @@ -10,7 +10,15 @@ def test_map_clustering_data_single_point(): """Test mapping clustering data for a single point""" - input_data = [{"longitude": 50.0, "latitude": 60.0, "count": 1, "uuid": "test-uuid"}] + input_data = [ + { + "longitude": 50.0, + "latitude": 60.0, + "count": 1, + "uuid": "test-uuid", + "marker": {"icon": "container"}, + } + ] result = map_clustering_data_to_proper_lazy_loading_object(input_data) @@ -20,6 +28,16 @@ def test_map_clustering_data_single_point(): assert result[0]["cluster_uuid"] is None assert result[0]["cluster_count"] is None assert result[0]["position"] == [50.0, 60.0] + assert result[0]["marker"] == {"icon": "container"} + + +def test_map_clustering_data_single_point_without_marker(): + """A point with no marker styling carries an explicit null, not a missing key.""" + input_data = [{"longitude": 50.0, "latitude": 60.0, "count": 1, "uuid": "test-uuid"}] + + result = map_clustering_data_to_proper_lazy_loading_object(input_data) + + assert result[0]["marker"] is None def test_map_clustering_data_cluster(): @@ -39,7 +57,7 @@ def test_map_clustering_data_cluster(): def test_match_clusters_uuids_exact_match(): """Test matching cluster UUIDs with exact coordinate match""" points = [ - {"position": [50.0, 60.0], "uuid": "uuid-1"}, + {"position": [50.0, 60.0], "uuid": "uuid-1", "marker": {"icon": "container"}}, {"position": [51.0, 61.0], "uuid": "uuid-2"}, ] @@ -51,7 +69,9 @@ def test_match_clusters_uuids_exact_match(): result = match_clusters_uuids(points, clusters) assert result[0]["uuid"] == "uuid-1" + assert result[0]["marker"] == {"icon": "container"} assert result[1]["uuid"] == "uuid-2" + assert result[1]["marker"] is None def test_match_clusters_uuids_multi_point_cluster(): @@ -67,8 +87,9 @@ def test_match_clusters_uuids_multi_point_cluster(): result = match_clusters_uuids(points, clusters) - # Multi-point cluster should not get a uuid assigned + # Multi-point cluster should not get a uuid/marker assigned assert "uuid" not in result[0] + assert "marker" not in result[0] def test_match_clusters_uuids_no_match_warning(): @@ -90,6 +111,7 @@ def test_match_clusters_uuids_no_match_warning(): warning_call = mock_logger.warning.call_args[0][0] assert "No matching UUID found" in warning_call assert result[0]["uuid"] is None + assert result[0]["marker"] is None def test_match_clusters_uuids_floating_point_precision(): diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index d8a3aab8..df11034f 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -272,6 +272,7 @@ def test_get_locations(test_app): { "uuid": "11111111-1111-1111-1111-111111111111", "position": [50, 50], + "marker": {"badge": True}, }, { "uuid": "22222222-2222-2222-2222-222222222222", @@ -313,150 +314,37 @@ def test_get_locations_accepts_valid_and_undeclared_parameters(test_app, query): assert response.status_code == 200 -# Fixture shared by the /api/locations and /api/locations/marker-styles tests -# below: point_type-categorized locker locations with a matching marker_styles -# config. Kept as data + a small factory, not one big db_overrides literal per -# test, so each test only states what it actually varies. -_LOCKER_LOCATIONS = [ - { - "name": "locker-1", - "position": [50, 50], - "point_type": "parcel_locker", - "uuid": "11111111-1111-1111-1111-111111111111", - }, - { - "name": "locker-2", - "position": [51, 51], - "point_type": "container", - "uuid": "22222222-2222-2222-2222-222222222222", - }, -] - - -def _create_marker_styles_test_app(data=_LOCKER_LOCATIONS, **db_overrides): - overrides = { - "categories": {"point_type": ["parcel_locker", "container"]}, - "location_obligatory_fields": [("point_type", "str"), ("name", "str")], - "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, - "data": data, - "visible_data": ["name", "point_type"], - } - overrides.update(db_overrides) - return create_test_app(db_overrides=overrides) - - -def test_get_locations_omits_marker_style_field_values(): - """/api/locations should not surface the field marker_styles.icon_field - points at (e.g. a point-type category) - that value is fetched lazily via - /api/locations/marker-styles, only once a marker is individually visible, - instead of upfront for every location (see lazy-load-marker-styling-plan.md).""" - client = _create_marker_styles_test_app(data=_LOCKER_LOCATIONS[:1]) - - response = client.get("/api/locations") - - assert response.status_code == 200 - assert response.json == [ - { - "uuid": "11111111-1111-1111-1111-111111111111", - "position": [50, 50], - }, - ] - - -def test_get_locations_marker_styles_returns_requested_uuids_styling(): - """The lazy marker-styles endpoint returns just the marker_styles-relevant - field values for the requested uuids, not the full location.""" - client = _create_marker_styles_test_app() - - response = client.get("/api/locations/marker-styles?uuid=11111111-1111-1111-1111-111111111111") - - assert response.status_code == 200 - assert response.json == { - "11111111-1111-1111-1111-111111111111": { - "has_remark": False, - "point_type": "parcel_locker", - }, - } - - -def test_get_locations_marker_styles_supports_multiple_uuids(): - client = _create_marker_styles_test_app() - - response = client.get( - "/api/locations/marker-styles" - "?uuid=11111111-1111-1111-1111-111111111111" - "&uuid=22222222-2222-2222-2222-222222222222" - ) - - assert response.status_code == 200 - assert response.json == { - "11111111-1111-1111-1111-111111111111": { - "has_remark": False, - "point_type": "parcel_locker", - }, - "22222222-2222-2222-2222-222222222222": { - "has_remark": False, - "point_type": "container", - }, - } - - -def test_get_locations_marker_styles_omits_unknown_uuids(): - """An unknown/re-clustered-away uuid doesn't error the whole request - it's - just absent from the response.""" - client = _create_marker_styles_test_app( - data=_LOCKER_LOCATIONS[:1], categories={"point_type": ["parcel_locker"]} - ) - - response = client.get( - "/api/locations/marker-styles" - "?uuid=11111111-1111-1111-1111-111111111111" - "&uuid=99999999-9999-9999-9999-999999999999" - ) - - assert response.status_code == 200 - assert response.json == { - "11111111-1111-1111-1111-111111111111": { - "has_remark": False, - "point_type": "parcel_locker", - }, - } - - -def test_get_locations_marker_styles_includes_has_remark_without_marker_styles_config(): - """has_remark drives the asterisk badge independently of marker_styles - - deployments with no icon_field/color_field configured still need it fetched - lazily, the same as everyone else.""" +def test_get_locations_includes_category_field_for_pin_styling(): + """/api/locations should surface the field marker_styles.icon_field points at + (e.g. a point-type category), so the frontend can pick a marker icon/color + without a full per-location detail fetch.""" client = create_test_app( db_overrides={ - "categories": {}, - "location_obligatory_fields": [("name", "str")], + "categories": {"point_type": ["parcel_locker", "container"]}, + "location_obligatory_fields": [("point_type", "str"), ("name", "str")], + "marker_styles": {"icon_field": "point_type", "icons": {}, "colors": {}}, "data": [ { - "name": "test", + "name": "locker-1", "position": [50, 50], + "point_type": "parcel_locker", "uuid": "11111111-1111-1111-1111-111111111111", - "remark": "this is a remark", }, ], + "visible_data": ["name", "point_type"], } ) - response = client.get("/api/locations/marker-styles?uuid=11111111-1111-1111-1111-111111111111") - - assert response.status_code == 200 - assert response.json == { - "11111111-1111-1111-1111-111111111111": {"has_remark": True}, - } - - -def test_get_locations_marker_styles_empty_query_returns_empty_object(): - client = create_test_app(db_overrides={"categories": {}}) - - response = client.get("/api/locations/marker-styles") + response = client.get("/api/locations") assert response.status_code == 200 - assert response.json == {} + assert response.json == [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "position": [50, 50], + "marker": {"icon": "parcel_locker"}, + }, + ] def test_get_locations_multi_value_same_category_uses_or_semantics(): @@ -1082,12 +970,24 @@ def test_location_clustering_high_zoom_no_clusters(test_app): assert data[1]["type"] == "point" +def test_location_clustering_point_entries_carry_marker(test_app): + """The clustered endpoint's point entries carry the same `marker` object as + /api/locations - the clustering pass must not drop it (goodmap/clustering.py).""" + response = test_app.get("/api/locations-clustered?zoom=16") + assert response.status_code == 200 + data = response.json + by_uuid = {entry["uuid"]: entry for entry in data} + assert by_uuid["11111111-1111-1111-1111-111111111111"]["marker"] == {"badge": True} + assert by_uuid["22222222-2222-2222-2222-222222222222"]["marker"] is None + + def test_location_clustering_low_zoom_creates_clusters(test_app): response = test_app.get("/api/locations-clustered?zoom=1") assert response.status_code == 200 data = response.json assert len(data) == 1 assert data[0]["type"] == "cluster" + assert "marker" not in data[0] @pytest.mark.parametrize( @@ -1183,6 +1083,7 @@ def test_issue_options_defaults_to_empty_when_missing(): def test_get_locations_from_request_helper(test_app): + from goodmap.api.api_models import PinMarkerFields from goodmap.api.core_api import get_locations_from_request class MockArgs: @@ -1192,7 +1093,9 @@ def to_dict(self, flat=False): mock_request_args = MockArgs() with test_app.application.app_context(): - locations = get_locations_from_request(test_app.application.db, mock_request_args) + locations = get_locations_from_request( + test_app.application.db, mock_request_args, PinMarkerFields() + ) assert isinstance(locations, list) if locations: assert isinstance(locations[0], dict) From bbbbd22567b64da17f6b7e25546a29f41aea82f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 13:32:19 +0200 Subject: [PATCH 31/45] removed compress --- goodmap/goodmap.py | 7 -- poetry.lock | 263 +-------------------------------------------- pyproject.toml | 1 - 3 files changed, 3 insertions(+), 268 deletions(-) diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 1e733384..fa9077d3 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -7,7 +7,6 @@ from typing import Any from flask import Blueprint, jsonify, redirect, render_template, session -from flask_compress import Compress from flask_wtf.csrf import CSRFError from platzky import platzky from platzky.config import languages_dict @@ -152,12 +151,6 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: extra_plugins_entrypoints=[_PLUGIN_ENTRY_POINT_GROUP], ) - # Compress JSON/text responses (gzip, or brotli/zstd if the client offers them). - # Not every deployment sits behind a proxy that already does this, and it's a - # sizeable win for /api/locations, whose repeated marker-styling field values - # compress especially well. - Compress(app) - frontend_static_dir = os.path.join(directory, "static", "frontend") app.register_blueprint( Blueprint( diff --git a/poetry.lock b/poetry.lock index 47bfa699..ace2010b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -277,103 +277,6 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] -[[package]] -name = "backports-zstd" -version = "1.7.0" -description = "Backport of compression.zstd" -optional = false -python-versions = "<3.14,>=3.10" -groups = ["main"] -markers = "python_version < \"3.14\"" -files = [ - {file = "backports_zstd-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c4f557bd8579d38316344c205b2a540e84b1014fb3721205eb6c3eb5322e9d9"}, - {file = "backports_zstd-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:68ee21f0efa3f06d3d3cbb5f291c177497fc550ebef732b0a38599de8db1ee32"}, - {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:beb8d6cf5ab3c27cca3a5fdcfeeb228885083d606f0709ffc0a698aabc4f13ee"}, - {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f1ecac082932870df519818e88eb938a03573245f629e34979141583112123"}, - {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0469fbe83c85f5a1fb83657242477ed612d4d4d3c000b67f8a67bc839115b09"}, - {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f53a23a40d25236aab6e0e817f2cbbf27e6f8fe976fedaa6b9ee53fc809b9"}, - {file = "backports_zstd-1.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b7929cbaff68c124d2366d803dc654347f37637a3df73a2a0a8f2dbee4819cc"}, - {file = "backports_zstd-1.7.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4108fa7b126fb4b08853670bb32c4a812aab355b8264aa1a27b7bb724ae6ce0"}, - {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1a868cff3de171b4961acd9fcf9e843cc966783aa0b2bdfdba876ec20023e24f"}, - {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e833fb85673c0a8c880dc3f759c87726680f953492e9275f666fcbfd127c6e8e"}, - {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3c32951fe1ae22f6f059d3c02cfdc21155cee83be456c424d955bf493ba2a9dc"}, - {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d312ff5018199e1f889ca470a98361feaf2d194f82091cbbd366bb539e7c3583"}, - {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a43e03d7769a06b5ccf4cad5fcf4b3e690b1b36476632d3e1bc923e12579963f"}, - {file = "backports_zstd-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff08fbdf4090c8075bcc0f3ffccf3098e4fd6a0d9a4c5c2078398ea5bb2ddd1f"}, - {file = "backports_zstd-1.7.0-cp310-cp310-win32.whl", hash = "sha256:e9e7bf426a21772b3ac1fe5c967678063d7bfcb58d2f559b98bf4c9c6c52f95f"}, - {file = "backports_zstd-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:02c4458f25f884131c59d54549a3bdfd649ca3384f1dd15204762171d9e24739"}, - {file = "backports_zstd-1.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:db2fb308ca3669e2913e66aae9173d9a9d5c448caaa2f1bdd12efbfe480f0fde"}, - {file = "backports_zstd-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:165a8898c5514b69533edf4ab1f4f4b4bacc62a137a76f36889b473150ec28a5"}, - {file = "backports_zstd-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:700ebb797956767679dbca38e45eaa5c21630e460e31ef53bb4b849125bb5d87"}, - {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:47f14a24428a2bc070e26c402f8d6d25676345c32fa116b16b60167a2925df2a"}, - {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c358e72e5ff8f23e9f3ec778be4d67ddc5ced3e6d8f03521db29d7357a773fc3"}, - {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8c183027eae38f5b0643d153f7f91e569d22ee8db25639aea0745677a38ed8"}, - {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d8493f71d9c5c05d18554afc6bb9a319a6674478e8f3042c7e22900c3a06f4d"}, - {file = "backports_zstd-1.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2e505d8923e1e9224cf249b99c92cf728e9eb91fbd1e07a9c2816013621fad3"}, - {file = "backports_zstd-1.7.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d1bdc293267200e31baf35aa142c6d0ac3e8cce650f79c84e6a32980dfbfd5c"}, - {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d85c18170e8cdba339edc67a5021cf79ccc858f5fda6aeae71f9015c5e463f6"}, - {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:96a6f8d3f4cefb6b11ebfc30fc0d970430ecfb169a6555990734a2a46977ec4b"}, - {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2c01cb823ed1b2422905a9791759bdc986e44e7a12b4661e9d712d5c8946016"}, - {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:86785aef2b4663a97c932d829ddc9565354cc628e2ae61764d9d93c8b186d65f"}, - {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:633ceee3ba86f696fc4e992f7bce558c132c26d04d64d0bb8c2f5d487d5b3aee"}, - {file = "backports_zstd-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a80bc6a8c9aeaad76cc3ecd58067ec038a764807186b0df970c760df39b89c7"}, - {file = "backports_zstd-1.7.0-cp311-cp311-win32.whl", hash = "sha256:1713271e2faea852a1682a6143c19c3506cd2e1b71f60a8924c59a9d2554d2b2"}, - {file = "backports_zstd-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae840be71108f6020567dd389c973e70a4374a6c0b03c02d3242c8a98a9b3cdb"}, - {file = "backports_zstd-1.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:8827a5601c749a986faa163f3b59d59eedc5947812be114f7132c3d4ad153fee"}, - {file = "backports_zstd-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5548a857bb0fcc5449cc3687353547396c6b1ecd4bd882f1cd34fa8d29e70ca"}, - {file = "backports_zstd-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bab192b934fdf5a03df4752556d9c8af2d058163fdfbafd4a253cdfe25449a6f"}, - {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:8344260bed9842c415a93d9bfe23ea834e5f27758827d56933d8c0d06db507a2"}, - {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c55e55e1e9dee312bc5e186386e6aa5207482a6d2242bd7c14709ded254f87f"}, - {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cf609af3735c7e697ccd13f6b0c88da57c201b6ea63c6afbfe81d6f9b50e298c"}, - {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:676a37971f676830d4f90cee8fdf4e438781596fb2f2d1984ac76c9b3eb39a69"}, - {file = "backports_zstd-1.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:470895d0bcddc850766e593d1b26764fb138c2feed149f515a2627ef9587d54c"}, - {file = "backports_zstd-1.7.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02f2f6649a342d0901ddb35596ddadb7c3bb1cf6bb54d691e5e0285f1fa0674f"}, - {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:132ba81fad59d44958b7d10da31545e7128c469cfbc2e268d0eaab96daa64175"}, - {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a3e1c6ce0b232ee6703ed24ee126e8186107f5a4e56edbd21cd1aa5a8c6bfd12"}, - {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d7a7cb964eb8d1bb5d039970b16fe54802ea47dc935ae96d9874844a126bf8ff"}, - {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:12a9842a2ec2854cbec7f252ab29d44c2b772788a9bbafded743ca4bf73b115f"}, - {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:138154eea8ced84394991bf0e819dba6b690306a178dd528c28eee724b7d4aec"}, - {file = "backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:468b636ed365627b364c94be1c35a52858e13b5bc1fa3f068bbc71b1af65f3d7"}, - {file = "backports_zstd-1.7.0-cp312-cp312-win32.whl", hash = "sha256:f026fe2e89b7ff01ba6ebec6abaff34c6063919151a32afb68714cf139e17c50"}, - {file = "backports_zstd-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:2ea62ba2f1a6e6c9e6dc108921f9ae881969ca72e073162fa488d0de3eb2713f"}, - {file = "backports_zstd-1.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:cefb983345c55ccaa20423a4eb96434730e6d640ffa2db9b60e5bedb0fbef94e"}, - {file = "backports_zstd-1.7.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:a3fbcbf819bee2b06b8666b13742098d0f40663ee34e64a12bc360ec0f5e3d89"}, - {file = "backports_zstd-1.7.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:efee02f18e04c2e9e6d694c5cf9b7457c4bda3ea96f48b1ee69769e06bb9d89f"}, - {file = "backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ecc95fa0e91d92951d74468e7789afdf91d9e702f40af2d0fcbf0ded4d0f650a"}, - {file = "backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34154d82fc0246738159084d146401073f9ac9cfd755b66bb8853ca06037810c"}, - {file = "backports_zstd-1.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44b687b1c0be5cb279693d2682f91ff84c559d679b2ef2fbe501fe4b2db2c4bb"}, - {file = "backports_zstd-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dcdbd368659f46b570114eeea36b75347716523870d71f6bc5d7801862aefd6e"}, - {file = "backports_zstd-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eda97fa535d4651a4ccdeed4ee7dde3978369046abc8a7456a7117d4271f9333"}, - {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7e3999b5141d7f85171822d06112f70f7f317d162f0120530dd2c7a28dbf8add"}, - {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69367726f4075c2574746f5883b0dc045805c5b02a81fdf8c829c26d33969de3"}, - {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15e97edfd173ade365c01bac7d9d297fa906686015cdbcb5f32a0d410887826b"}, - {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:32a94cdcf16b44395cd55086ea38877395ca6bf3362cb507b0eb86db2a45a6a4"}, - {file = "backports_zstd-1.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4887a8a1fd1290017fe5a1d29a7d1dc5c57f9477fbd64f119316a7e3ae769"}, - {file = "backports_zstd-1.7.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e590313ce156f1d8986dff3107e8ed1651d6d106a56b3a95f965ff8d845ba979"}, - {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:565270b0d6497970fa97a0df59593ae0d225e4176678bbce851d39e5f8aa422b"}, - {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:37ef23c6c522fe935726c8fba6344350973c4a23b06d10194d90d0868b09ff7a"}, - {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b3975330159f1efdd1fba76afe1c7b84f66f26e2bf209b32630fb148d647e0d5"}, - {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b40bc8cd0a86cbbe8263a9c3a2bf2e34897483516c6d799725412a19524c32e3"}, - {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f37e12ef10747f76901b1f20ef70d33221e861de177dba5ba08552242c6fd4bd"}, - {file = "backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5992143b2a8b71b4d17afed20cce2df50f8718228e31d6e716493b1fe9201712"}, - {file = "backports_zstd-1.7.0-cp313-cp313-win32.whl", hash = "sha256:31ae30d216ffae9243dfa607bcb995f94a70de5765bb8fae1e35ea1ad6497959"}, - {file = "backports_zstd-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:8086b4a7443bb2863f7ef8edb317b715d5f3ccec6c5512619bd23d57661ba1b7"}, - {file = "backports_zstd-1.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:7eaceeec75e1dbdce40b81fb0ed1ffdb7ce492d970db7f8aabd6a95ccd6c3dd3"}, - {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8d59b145d379745c4461adbe9a9afc647f90ca164f50ea2566c08d6601531d1c"}, - {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d3c3cda113aacabe7fd0594ad2832b7023a5fee84009406fe4d230906d80fb25"}, - {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f8da4758af21788a9a90f56b7f658a35d33034e55d416fd40e8bcfbb347b90c2"}, - {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e27916c92272ab4285d8d2e02eebe5f4198da10d82250b6edfa3ce372aff6f79"}, - {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:76e205599a60acc0824bf03522fb9ad25449492535e1efba18f047e2ce48e745"}, - {file = "backports_zstd-1.7.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2feaefcca77c6ac97a46a64f9d41c429caa135a837c54b46398022716abd8184"}, - {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:de58be0a3109cfb83b4e61e2b6eb770201cc132ee5a7c677cd8e0140ad2be80c"}, - {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c13f73d0389cdc88b02c05e8175d8ad3030e9e70ee079748763166aa843b647d"}, - {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:a2e30ea49c673533d40eb73d0f7abc0ebe9d2e4fc6dbada5ad60b42ff98ffa86"}, - {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e3f760ee9d16378e3cde9d862e1c9ced577a86736763fb486b9f731d5116807"}, - {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25caf23dc36de3b839d16c25893751323cf51a8c986f2d01478c16b25133e2e8"}, - {file = "backports_zstd-1.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a64e796c7eee69dfe45827b2e003b7731785ec890c73ea5f5fbc30a1c362fcad"}, - {file = "backports_zstd-1.7.0.tar.gz", hash = "sha256:1a967189c1822b6e85a2e550fdfc88a3272c17633ea0a4732dac5911a8034f2b"}, -] - [[package]] name = "black" version = "26.3.1" @@ -457,149 +360,6 @@ docs = ["Sphinx (>=3.3.1)", "doc8 (>=0.8.1)", "sphinx-rtd-theme (>=0.5.0)", "sph linting = ["black", "isort", "pycodestyle"] testing = ["pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)"] -[[package]] -name = "brotli" -version = "1.2.0" -description = "Python bindings for the Brotli compression library" -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "brotli-1.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8"}, - {file = "brotli-1.2.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a"}, - {file = "brotli-1.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92"}, - {file = "brotli-1.2.0-cp27-cp27m-win32.whl", hash = "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb"}, - {file = "brotli-1.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f"}, - {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f"}, - {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46"}, - {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e"}, - {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984"}, - {file = "brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de"}, - {file = "brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947"}, - {file = "brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2"}, - {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84"}, - {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d"}, - {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1"}, - {file = "brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997"}, - {file = "brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196"}, - {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744"}, - {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f"}, - {file = "brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd"}, - {file = "brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe"}, - {file = "brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a"}, - {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b"}, - {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3"}, - {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae"}, - {file = "brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03"}, - {file = "brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24"}, - {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84"}, - {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b"}, - {file = "brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d"}, - {file = "brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca"}, - {file = "brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f"}, - {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28"}, - {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7"}, - {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036"}, - {file = "brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161"}, - {file = "brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44"}, - {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab"}, - {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c"}, - {file = "brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f"}, - {file = "brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6"}, - {file = "brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c"}, - {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48"}, - {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18"}, - {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5"}, - {file = "brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a"}, - {file = "brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8"}, - {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21"}, - {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac"}, - {file = "brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e"}, - {file = "brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7"}, - {file = "brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63"}, - {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b"}, - {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361"}, - {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888"}, - {file = "brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d"}, - {file = "brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3"}, - {file = "brotli-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518"}, - {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69"}, - {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e"}, - {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7"}, - {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0"}, - {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea"}, - {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502"}, - {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64"}, - {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533"}, - {file = "brotli-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96"}, - {file = "brotli-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13"}, - {file = "brotli-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8"}, - {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8"}, - {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc"}, - {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6"}, - {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190"}, - {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a"}, - {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12"}, - {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3"}, - {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a"}, - {file = "brotli-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982"}, - {file = "brotli-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16"}, - {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8"}, - {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990"}, - {file = "brotli-1.2.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526"}, - {file = "brotli-1.2.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2"}, - {file = "brotli-1.2.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675"}, - {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d"}, - {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5"}, - {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7"}, - {file = "brotli-1.2.0-cp38-cp38-win32.whl", hash = "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c"}, - {file = "brotli-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470"}, - {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1"}, - {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17"}, - {file = "brotli-1.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971"}, - {file = "brotli-1.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e"}, - {file = "brotli-1.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8"}, - {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a"}, - {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b"}, - {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4"}, - {file = "brotli-1.2.0-cp39-cp39-win32.whl", hash = "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49"}, - {file = "brotli-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937"}, - {file = "brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a"}, -] - -[[package]] -name = "brotlicffi" -version = "1.2.0.1" -description = "Python CFFI bindings to the Brotli library" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" -files = [ - {file = "brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd"}, - {file = "brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5"}, - {file = "brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac"}, - {file = "brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec"}, - {file = "brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000"}, - {file = "brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4"}, - {file = "brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce"}, - {file = "brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a"}, - {file = "brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187"}, - {file = "brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede"}, - {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851"}, - {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf"}, - {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4"}, - {file = "brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1"}, - {file = "brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c"}, -] - -[package.dependencies] -cffi = [ - {version = ">=1.17.0", markers = "python_version >= \"3.13\""}, - {version = ">=1.0.0", markers = "python_version < \"3.13\""}, -] - [[package]] name = "cachecontrol" version = "0.14.4" @@ -653,6 +413,7 @@ description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -1200,24 +961,6 @@ Flask = ">=2.0" Jinja2 = ">=3.1" pytz = ">=2022.7" -[[package]] -name = "flask-compress" -version = "1.24" -description = "Compress responses in your Flask app with gzip, deflate, brotli or zstandard." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c"}, - {file = "flask_compress-1.24.tar.gz", hash = "sha256:14097cefe59ecb3e466d52a6aeb62f34f125a9f7dadf1f33a53e430ce4a50f31"}, -] - -[package.dependencies] -"backports.zstd" = {version = "*", markers = "python_version < \"3.14\""} -brotli = {version = "*", markers = "platform_python_implementation != \"PyPy\""} -brotlicffi = {version = "*", markers = "platform_python_implementation == \"PyPy\""} -flask = "*" - [[package]] name = "flask-minify" version = "0.50" @@ -2689,7 +2432,7 @@ description = "C parser in Python" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "implementation_name != \"PyPy\"" +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, @@ -4245,4 +3988,4 @@ docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "6ff5abb355a448795fa4104f632c63dd0378dc8690117a6b7e402d57b0dbc63d" +content-hash = "f20b77cfa8f9e22f65d2a36a61a3c385a71ea8ee8a31e879b5ee286bbc5e133f" diff --git a/pyproject.toml b/pyproject.toml index 3af8a031..3f412599 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ scipy = "^1.15.1" sphinx = {version = "^8.0.0", optional = true} sphinx-rtd-theme = {version = "^3.0.0", optional = true} myst-parser = {version = "^4.0.0", optional = true} -flask-compress = "^1.24" [tool.poetry.extras] docs = [ From bda07f7eea1da830c6008c09a593e90edfb2367a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 13:48:19 +0200 Subject: [PATCH 32/45] fix sonar errors --- frontend/webpack.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 1d328354..8eae9877 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -6,7 +6,7 @@ const deps = require('./package.json').dependencies; module.exports = (env, argv) => { const IS_PROD = argv.mode === 'production'; - const runOnAllInterfaces = env && env.serve === 'network'; + const runOnAllInterfaces = env?.serve === 'network'; return { plugins: [ @@ -28,7 +28,7 @@ module.exports = (env, argv) => { // dev-server-only constructs (e.g. HarmonyAcceptDependency) it doesn't // know how to handle, crashing with "Invalid value used as weak map // key". Keying the cache name off env.serve keeps the two fully apart. - name: env && env.serve ? 'dev-server' : 'build', + name: env?.serve ? 'dev-server' : 'build', cacheDirectory: path.resolve(__dirname, '.webpack-cache'), buildDependencies: { config: [__filename], From 746c996ac9b0c634fdcad0f4a8447d9bad7d4389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 15:49:49 +0200 Subject: [PATCH 33/45] fixes after review --- docs/data-source.rst | 11 +++++-- e2e-tests/e2e_test_data_initial.json | 1 + .../components/MarkerPopup/MarkerPopup.jsx | 8 +++-- .../MarkerPopup/getTypedMarkerIcon.jsx | 16 ++++++++-- .../tests/MarkerPopup/MarkerPopup.test.jsx | 31 ++++++++++++++++++ .../MarkerPopup/getTypedMarkerIcon.test.jsx | 18 +++++++++++ goodmap/api/core_api.py | 2 +- goodmap/clustering.py | 8 +++-- goodmap/db.py | 8 ++--- goodmap/goodmap.py | 9 +++--- tests/unit_tests/test_clustering.py | 5 +-- tests/unit_tests/test_core_api.py | 2 +- tests/unit_tests/test_db.py | 24 ++++++++++++++ tests/unit_tests/test_goodmap.py | 32 +++++++++++++++++++ 14 files changed, 154 insertions(+), 21 deletions(-) diff --git a/docs/data-source.rst b/docs/data-source.rst index 79b3325e..2c4a284e 100644 --- a/docs/data-source.rst +++ b/docs/data-source.rst @@ -80,6 +80,8 @@ Everything else is yours. Custom fields are only *shown* if you list them in Field schema ------------ +.. _data-model-location_obligatory_fields: + ``location_obligatory_fields`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -282,8 +284,13 @@ a map with no ``marker_styles`` still renders, just with plain pins. } ``icon_field``, ``color_field`` - Names of fields on your points (typically ones already listed in ``categories``) - whose *value* selects the icon/color for that point. Either or both may be omitted. + Names of fields on your points whose *value* selects the icon/color for that point. + Either or both may be omitted. + + Each must be declared in :ref:`data-model-location_obligatory_fields` — a field + every point is guaranteed to have, so that styling is never driven by something + only some of your points carry. A name that isn't declared there is ignored, and + pins get no icon/color from it. ``icons`` Maps a value of ``icon_field`` to either a plain URL string, or diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index 14abbe29..0f4e7474 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -112,6 +112,7 @@ "is_free": "true", "speed_limit": "10", "amenities": [ + "benches", "toilets" ], "uuid": "5986e755-1eaa-4121-a01c-4fef1d5d1da1" diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index d06e9dda..1c5213c7 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import { Marker } from 'react-leaflet'; import { isMobile } from 'react-device-detect'; @@ -102,6 +102,11 @@ const MarkerPopup = ({ place }) => { setIsClicked(true); }; + // react-leaflet compares `icon` by identity, so a fresh DivIcon on every render + // would tear down and rebuild the marker's DOM - and every MarkerPopup re-renders + // whenever anything writes selectedLocationId. Only place.marker can change it. + const typedIcon = useMemo(() => getTypedMarkerIcon(place), [place.marker]); + const markerProps = { position: place.position, eventHandlers: { @@ -109,7 +114,6 @@ const MarkerPopup = ({ place }) => { }, }; - const typedIcon = getTypedMarkerIcon(place); if (typedIcon) { markerProps.icon = typedIcon; } diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 4b724431..474f9e01 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -41,6 +41,18 @@ const resolveIconUrl = icon => { return ''; }; +/** + * Own-property lookup in a config table. A point's field value is arbitrary data, + * so a plain `table[key]` would resolve "toString"/"constructor" to an inherited + * function - truthy, and interpolated straight into the pin's CSS. + * + * @param {Object|undefined} table - icons/colors from MARKER_STYLES + * @param {*} key - this point's raw icon_field/color_field value + * @returns {*} The matching entry, or undefined + */ +const lookup = (table, key) => + table != null && Object.hasOwn(table, key) ? table[key] : undefined; + const maskStyle = (url, color) => ({ backgroundColor: color, WebkitMaskImage: `url(${url})`, @@ -128,8 +140,8 @@ const getTypedMarkerIcon = place => { const { icons, colors } = globalThis.MARKER_STYLES || {}; const marker = place.marker || {}; - const typeIconUrl = resolveIconUrl(icons?.[marker.icon]); - const matchedColor = colors?.[marker.color] || ''; + const typeIconUrl = resolveIconUrl(lookup(icons, marker.icon)); + const matchedColor = lookup(colors, marker.color) || ''; const hasRemark = Boolean(marker.badge); if (!typeIconUrl && !matchedColor && !hasRemark) { diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index c0da8e4b..9acf7e3c 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import '@testing-library/jest-dom'; import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import { MapContainer } from 'react-leaflet'; +import { Marker as LeafletMarker } from 'leaflet'; import MarkerPopup from '../../src/components/MarkerPopup/MarkerPopup'; import httpService from '../../src/services/http/httpService'; @@ -149,4 +150,34 @@ describe('MarkerPopup with remark', () => { expect(style.width).toBe('45px'); expect(style.height).toBe('50px'); }); + + it('does not rebuild the icon on a re-render that leaves place.marker alone', () => { + // react-leaflet compares icon by identity (updateMarker in react-leaflet/lib/Marker.js), + // so a fresh DivIcon per render re-runs setIcon - and with it renderToString and a + // full rewrite of the pin - on every store write, for every marker on the map. + const setIcon = jest.spyOn(LeafletMarker.prototype, 'setIcon'); + const locationWithRemark = { ...location, marker: { badge: true } }; + const tree = zoom => ( + + + + ); + + let rerender; + act(() => { + ({ rerender } = render(tree(10))); + }); + setIcon.mockClear(); + + act(() => { + rerender(tree(11)); + }); + + expect(setIcon).not.toHaveBeenCalled(); + setIcon.mockRestore(); + }); }); diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index 1b8b633b..d7649855 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -36,6 +36,24 @@ describe('getTypedMarkerIcon', () => { ).toBeNull(); }); + it.each(['toString', 'constructor', 'valueOf', 'hasOwnProperty'])( + 'returns null for the inherited Object.prototype member %s', + member => { + setMarkerStyles(`{ + "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" }, + "colors": { "open": "#2e7d32" } + }`); + + expect( + getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { icon: member, color: member }, + }), + ).toBeNull(); + }, + ); + it('builds a DivIcon when marker.icon matches a configured type icon', () => { setMarkerStyles(`{ "icons": { "parcelLocker": "https://cdn.example.com/parcel-locker.svg" } diff --git a/goodmap/api/core_api.py b/goodmap/api/core_api.py index 40965f9b..3bf188e0 100644 --- a/goodmap/api/core_api.py +++ b/goodmap/api/core_api.py @@ -204,7 +204,7 @@ def core_pages( photo_attachment_config: AttachmentConfig, feature_flags: FeatureFlagSet, shortcodes: dict[str, Shortcode], - pin_marker_fields: PinMarkerFields = PinMarkerFields(), + pin_marker_fields: PinMarkerFields, ) -> Blueprint: core_api_blueprint = Blueprint("api", __name__, url_prefix="/api") diff --git a/goodmap/clustering.py b/goodmap/clustering.py index fff42eee..1528893f 100644 --- a/goodmap/clustering.py +++ b/goodmap/clustering.py @@ -29,7 +29,8 @@ def map_clustering_data_to_proper_lazy_loading_object(input_array): Returns: List of response dicts with 'position', 'uuid', 'cluster_uuid', - 'cluster_count', 'type' and 'marker' keys. + 'cluster_count' and 'type' keys, plus 'marker' for points that have + any pin styling. """ response_array = [] for item in input_array: @@ -40,8 +41,11 @@ def map_clustering_data_to_proper_lazy_loading_object(input_array): "cluster_uuid": None, "cluster_count": None, "type": "point", - "marker": item.get("marker"), } + # Left out entirely rather than sent as null for an unstyled point, so a + # point here looks exactly like the same point from /api/locations. + if item.get("marker") is not None: + response_object["marker"] = item["marker"] response_array.append(response_object) continue response_object = { diff --git a/goodmap/db.py b/goodmap/db.py index a1213650..3b92263a 100644 --- a/goodmap/db.py +++ b/goodmap/db.py @@ -632,13 +632,13 @@ def get_marker_styles(db): def json_db_get_categories(self): """Return category keys from in-memory JSON database.""" - return self.data["categories"].keys() + return self.data.get("categories", {}).keys() def json_file_db_get_categories(self): """Return category keys from JSON file database.""" with open(self.data_file_path, "r") as file: - return json.load(file)["map"]["categories"].keys() + return json.load(file)["map"].get("categories", {}).keys() def google_json_db_get_categories(self): @@ -845,7 +845,7 @@ def get_locations_list_from_raw_data(map_data, query, location_model): """Filter and validate locations from raw map data based on query parameters. Args: - map_data: Dict containing 'data' and 'categories' keys. + map_data: Dict containing a 'data' key, and optionally 'categories'. query: Dict of query parameters for filtering. location_model: Pydantic model class to validate each location. @@ -854,7 +854,7 @@ def get_locations_list_from_raw_data(map_data, query, location_model): """ filtered_locations = get_queried_data( map_data["data"], - map_data["categories"], + map_data.get("categories", {}), query, map_data.get("categories_filter_mode", {}), ) diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index fa9077d3..a967be31 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -284,11 +284,10 @@ def index(): Returns: Rendered map.html template with feature flags and the plugin manifest """ - try: - marker_styles = app.db.get_marker_styles() # type: ignore[attr-defined] - except (KeyError, AttributeError): - marker_styles = {} - + # The startup-time marker_styles, not a fresh read: pin_marker_fields (which + # decides what /api/locations puts in marker.icon/color) is frozen at startup + # too, so re-reading here would hand the frontend lookup tables keyed on a + # field the API is no longer sending values from. return render_template( "map.html", feature_flags=config.feature_flags, diff --git a/tests/unit_tests/test_clustering.py b/tests/unit_tests/test_clustering.py index 64bdbf15..cd1fedb8 100644 --- a/tests/unit_tests/test_clustering.py +++ b/tests/unit_tests/test_clustering.py @@ -32,12 +32,13 @@ def test_map_clustering_data_single_point(): def test_map_clustering_data_single_point_without_marker(): - """A point with no marker styling carries an explicit null, not a missing key.""" + """A point with no marker styling has no `marker` key at all, exactly as the + same point comes back from /api/locations.""" input_data = [{"longitude": 50.0, "latitude": 60.0, "count": 1, "uuid": "test-uuid"}] result = map_clustering_data_to_proper_lazy_loading_object(input_data) - assert result[0]["marker"] is None + assert "marker" not in result[0] def test_map_clustering_data_cluster(): diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index df11034f..cc94b045 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -978,7 +978,7 @@ def test_location_clustering_point_entries_carry_marker(test_app): data = response.json by_uuid = {entry["uuid"]: entry for entry in data} assert by_uuid["11111111-1111-1111-1111-111111111111"]["marker"] == {"badge": True} - assert by_uuid["22222222-2222-2222-2222-222222222222"]["marker"] is None + assert "marker" not in by_uuid["22222222-2222-2222-2222-222222222222"] def test_location_clustering_low_zoom_creates_clusters(test_app): diff --git a/tests/unit_tests/test_db.py b/tests/unit_tests/test_db.py index 9759c97c..1f85f96c 100644 --- a/tests/unit_tests/test_db.py +++ b/tests/unit_tests/test_db.py @@ -1496,6 +1496,30 @@ def test_json_file_db_get_categories(tmp_path): assert list(categories) == ["test-category"] +def test_json_db_without_categories_serves_locations(): + """A data source with no `categories` at all is a valid setup - a map of plain, + unfilterable points - so neither listing categories nor querying locations may + depend on the key being there.""" + uncategorized = {key: value for key, value in data.items() if key != "categories"} + db = in_memory_json_db(uncategorized) + extend_db_with_goodmap_queries(db, LocationBase) + + assert list(json_db_get_categories(db)) == [] + assert len(db.get_locations({})) == 2 + + +def test_json_file_db_without_categories_serves_locations(tmp_path): + uncategorized = {key: value for key, value in data.items() if key != "categories"} + test_file = tmp_path / "test.json" + test_file.write_text(json.dumps({"map": uncategorized})) + + db = JsonFile(str(test_file)) + extend_db_with_goodmap_queries(db, LocationBase) + + assert list(json_file_db_get_categories(db)) == [] + assert len(db.get_locations({})) == 2 + + @mock.patch("platzky.db.google_json_db.Client") def test_google_json_db_get_categories(mock_cli): mock_cli.return_value.bucket.return_value.blob.return_value.download_as_text.return_value = ( diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index aa7b79dd..8452f3cf 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -160,6 +160,38 @@ def test_map_route_marker_styles(): assert "window.MARKER_STYLES={};" in response.data.decode("utf-8") +def test_map_route_marker_styles_stay_in_step_with_the_api(): + """window.MARKER_STYLES comes from the startup-time config, not a fresh read per + request. The field /api/locations reads marker.icon from is fixed at startup, so a + /map that served newer lookup tables would key them on values the API isn't + sending.""" + data = { + "site_content": {"pages": []}, + "location_obligatory_fields": [["type_of_place", "str"]], + "marker_styles": { + "icon_field": "type_of_place", + "icons": {"parcel_locker": "https://cdn.example.com/package.svg"}, + "colors": {}, + }, + } + app = goodmap.create_app_from_config( + GoodmapConfig( + APP_NAME="test_app", + SECRET_KEY="test_secret", + USE_WWW=False, + BLOG_PREFIX="/blog", + DB=JsonDbConfig(DATA=data, TYPE="json"), + ) + ) + app.config["WTF_CSRF_ENABLED"] = False # NOSONAR + + app.db.data["marker_styles"] = {"icon_field": "something_else", "icons": {}, "colors": {}} + + response_text = app.test_client().get("/map").data.decode("utf-8") + assert "parcel_locker" in response_text + assert "something_else" not in response_text + + def test_map_route_includes_photo_constraints(): """The frontend sources photo upload limits (max size, allowed types) live from the backend's AttachmentConfig rather than hardcoding its own copy - this test From d316152ad00447651d96882b5cef0af532be5f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 15:59:53 +0200 Subject: [PATCH 34/45] lint fixes --- tests/unit_tests/test_db.py | 8 ++++---- tests/unit_tests/test_goodmap.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit_tests/test_db.py b/tests/unit_tests/test_db.py index 1f85f96c..84270b19 100644 --- a/tests/unit_tests/test_db.py +++ b/tests/unit_tests/test_db.py @@ -39,6 +39,7 @@ json_db_get_category_data, json_db_get_data, json_db_get_location_obligatory_fields, + json_db_get_locations, json_db_get_report, json_db_get_reports, json_db_get_suggestion, @@ -57,6 +58,7 @@ json_file_db_get_category_data, json_file_db_get_data, json_file_db_get_location_obligatory_fields, + json_file_db_get_locations, json_file_db_get_locations_paginated, json_file_db_get_marker_styles, json_file_db_get_meta_data, @@ -1502,10 +1504,9 @@ def test_json_db_without_categories_serves_locations(): depend on the key being there.""" uncategorized = {key: value for key, value in data.items() if key != "categories"} db = in_memory_json_db(uncategorized) - extend_db_with_goodmap_queries(db, LocationBase) assert list(json_db_get_categories(db)) == [] - assert len(db.get_locations({})) == 2 + assert len(json_db_get_locations(db, {}, LocationBase)) == 2 def test_json_file_db_without_categories_serves_locations(tmp_path): @@ -1514,10 +1515,9 @@ def test_json_file_db_without_categories_serves_locations(tmp_path): test_file.write_text(json.dumps({"map": uncategorized})) db = JsonFile(str(test_file)) - extend_db_with_goodmap_queries(db, LocationBase) assert list(json_file_db_get_categories(db)) == [] - assert len(db.get_locations({})) == 2 + assert len(json_file_db_get_locations(db, {}, LocationBase)) == 2 @mock.patch("platzky.db.google_json_db.Client") diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 8452f3cf..28966991 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -185,11 +185,11 @@ def test_map_route_marker_styles_stay_in_step_with_the_api(): ) app.config["WTF_CSRF_ENABLED"] = False # NOSONAR - app.db.data["marker_styles"] = {"icon_field": "something_else", "icons": {}, "colors": {}} + with mock.patch.object(app.db, "get_marker_styles") as fresh_read: + response_text = app.test_client().get("/map").data.decode("utf-8") - response_text = app.test_client().get("/map").data.decode("utf-8") + fresh_read.assert_not_called() assert "parcel_locker" in response_text - assert "something_else" not in response_text def test_map_route_includes_photo_constraints(): From 77bf36ef856dccdde986909c7165e9251fdce7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 18:30:38 +0200 Subject: [PATCH 35/45] fix for ci problems --- .../src/components/MarkerPopup/MarkerPopup.jsx | 5 +++-- frontend/tests/MarkerPopup/MarkerPopup.test.jsx | 17 ++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/MarkerPopup/MarkerPopup.jsx b/frontend/src/components/MarkerPopup/MarkerPopup.jsx index 1c5213c7..2b117279 100644 --- a/frontend/src/components/MarkerPopup/MarkerPopup.jsx +++ b/frontend/src/components/MarkerPopup/MarkerPopup.jsx @@ -104,8 +104,9 @@ const MarkerPopup = ({ place }) => { // react-leaflet compares `icon` by identity, so a fresh DivIcon on every render // would tear down and rebuild the marker's DOM - and every MarkerPopup re-renders - // whenever anything writes selectedLocationId. Only place.marker can change it. - const typedIcon = useMemo(() => getTypedMarkerIcon(place), [place.marker]); + // whenever anything writes selectedLocationId. `place` is one entry of the fetched + // location list (see Markers.jsx), so its identity only changes on a refetch. + const typedIcon = useMemo(() => getTypedMarkerIcon(place), [place]); const markerProps = { position: place.position, diff --git a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx index 9acf7e3c..0cc8c1e5 100644 --- a/frontend/tests/MarkerPopup/MarkerPopup.test.jsx +++ b/frontend/tests/MarkerPopup/MarkerPopup.test.jsx @@ -167,17 +167,16 @@ describe('MarkerPopup with remark', () => { ); - let rerender; - act(() => { - ({ rerender } = render(tree(10))); - }); + // render/rerender already flush their own updates, so no act() wrapper here. + const { rerender } = render(tree(10)); setIcon.mockClear(); - act(() => { - rerender(tree(11)); - }); + rerender(tree(11)); - expect(setIcon).not.toHaveBeenCalled(); - setIcon.mockRestore(); + try { + expect(setIcon).not.toHaveBeenCalled(); + } finally { + setIcon.mockRestore(); + } }); }); From d45271c4f734d1e475268686ffaf586dc2cb7a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 19:15:55 +0200 Subject: [PATCH 36/45] fixes --- docs/data-source.rst | 8 +- .../MarkerPopup/getTypedMarkerIcon.jsx | 14 +- frontend/webpack.config.js | 7 - goodmap/goodmap.py | 6 +- goodmap/templates/map.html | 4 +- tests/unit_tests/test_goodmap.py | 33 +++++ tests/unit_tests/test_marker_styles.py | 133 ++++++++++++++++++ 7 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 tests/unit_tests/test_marker_styles.py diff --git a/docs/data-source.rst b/docs/data-source.rst index 2c4a284e..1bada206 100644 --- a/docs/data-source.rst +++ b/docs/data-source.rst @@ -263,7 +263,7 @@ Marker styles ------------- ``marker_styles`` picks which of your fields drive each point's pin icon and color, and -supplies the lookup tables the frontend resolves them through. It is entirely optional — +supplies the lookup tables those values are resolved through. It is entirely optional — a map with no ``marker_styles`` still renders, just with plain pins. .. code-block:: json @@ -296,6 +296,12 @@ a map with no ``marker_styles`` still renders, just with plain pins. Maps a value of ``icon_field`` to either a plain URL string, or ``{"provider": "phosphor", "value": ""}`` to use a `Phosphor `_ icon by name instead of hosting your own SVG. + ``{"provider": "url", "value": "..."}`` is the plain string spelled out explicitly. + + GoodMap turns these into plain URLs when the app starts, so the browser only ever + receives finished URLs. An entry it cannot make sense of — an unknown ``provider``, a + missing ``value`` — is logged as a warning at startup and left out, costing that one + pin its icon rather than breaking the map. ``colors`` Maps a value of ``color_field`` to a CSS color. diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index 474f9e01..fd6c1a11 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -16,11 +16,15 @@ const TYPE_ICON_OFFSET_TOP = 8; const TYPE_ICON_OFFSET_LEFT = 12; /** - * Resolves a configured marker_styles.icons entry to a usable URL. Supports a - * plain string (a direct URL, as before) and a tagged {provider, value} object: - * provider "phosphor" builds a jsdelivr CDN URL for value (an icon name), see - * resolvePhosphorIconUrl; provider "url" is the same as a plain string, spelled - * out explicitly. + * Resolves a configured marker_styles.icons entry to a usable URL. + * + * A current backend already resolves icons to plain URL strings (see goodmap's + * marker_styles.py), so that is the only branch this normally takes. The tagged + * {provider, value} branches remain as a compatibility shim, so this bundle also + * works against an older backend that still ships the unresolved form: provider + * "phosphor" builds a jsdelivr CDN URL for value (an icon name), see + * resolvePhosphorIconUrl; provider "url" is a plain string spelled out explicitly. + * They can go once such backends are no longer supported. * * @param {string|{provider: string, value: string}|undefined} icon * @returns {string} A usable URL, or '' if icon is unset diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 8eae9877..423790dc 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -21,13 +21,6 @@ module.exports = (env, argv) => { ], cache: { type: 'filesystem', - // webpack-dev-server (serve:local/serve:prod/serve:network, all pass - // --env serve=...) injects HMR machinery into its build even under - // --mode production - sharing a cache namespace with the plain `build` - // script (same mode, no --env) corrupts it: a later plain build can hit - // dev-server-only constructs (e.g. HarmonyAcceptDependency) it doesn't - // know how to handle, crashing with "Invalid value used as weak map - // key". Keying the cache name off env.serve keeps the two fully apart. name: env?.serve ? 'dev-server' : 'build', cacheDirectory: path.resolve(__dirname, '.webpack-cache'), buildDependencies: { diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index a967be31..d4acbb0b 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -26,6 +26,7 @@ get_marker_styles, ) from goodmap.feature_flags import EnableAdminPanel +from goodmap.marker_styles import resolve_marker_styles from goodmap.plugin import CAPABILITY_BASES, GoodmapPluginBase logger = logging.getLogger(__name__) @@ -173,9 +174,12 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: # with the query functions it needs. categories/marker_styles are both optional # (see docs/data-source.rst) - every backend's get_category_data()/ # get_marker_styles() already defaults them to {} internally. + # + # marker_styles is resolved once, here, so window.MARKER_STYLES.icons is a flat + # {value: url} table and the frontend never has to know about icon providers. location_obligatory_fields = get_location_obligatory_fields(app.db) categories = get_category_data(app.db)(app.db)["categories"] - marker_styles = get_marker_styles(app.db)(app.db) + marker_styles = resolve_marker_styles(get_marker_styles(app.db)(app.db)) location_model = create_location_model(location_obligatory_fields, categories) app.db = extend_db_with_goodmap_queries(app.db, location_model) diff --git a/goodmap/templates/map.html b/goodmap/templates/map.html index 4852a8a9..fb18e967 100644 --- a/goodmap/templates/map.html +++ b/goodmap/templates/map.html @@ -120,7 +120,9 @@ window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }}; window.FEATURE_FLAGS = {{ feature_flags | tojson }}; window.PLUGIN_MANIFEST = {{ plugin_manifest | tojson }}; -// Deployment-specific pin icon/color lookup table, see goodmap/db.py's get_marker_styles. +// Deployment-specific pin icon/color lookup table. icons are already resolved to plain +// URLs here - see goodmap/marker_styles.py's resolve_marker_styles; the raw stored +// config (which may use {provider, value}) comes from goodmap/db.py's get_marker_styles. window.MARKER_STYLES = {{ marker_styles | tojson }}; diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 28966991..ce5e37ac 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -192,6 +192,39 @@ def test_map_route_marker_styles_stay_in_step_with_the_api(): assert "parcel_locker" in response_text +def test_map_route_serves_icons_already_resolved_to_urls(): + """window.MARKER_STYLES.icons is a flat {value: url} table: the tagged + {provider, value} form a data source may use is resolved at startup, so supporting a + new provider never needs a frontend release.""" + data = { + "site_content": {"pages": []}, + "location_obligatory_fields": [["type_of_place", "str"]], + "marker_styles": { + "icon_field": "type_of_place", + "icons": {"big bridge": {"provider": "phosphor", "value": "bridge"}}, + "colors": {}, + }, + } + app = goodmap.create_app_from_config( + GoodmapConfig( + APP_NAME="test_app", + SECRET_KEY="test_secret", + USE_WWW=False, + BLOG_PREFIX="/blog", + DB=JsonDbConfig(DATA=data, TYPE="json"), + ) + ) + app.config["WTF_CSRF_ENABLED"] = False # NOSONAR + + response_text = app.test_client().get("/map").data.decode("utf-8") + + assert ( + "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" + in response_text + ) + assert "provider" not in response_text + + def test_map_route_includes_photo_constraints(): """The frontend sources photo upload limits (max size, allowed types) live from the backend's AttachmentConfig rather than hardcoding its own copy - this test diff --git a/tests/unit_tests/test_marker_styles.py b/tests/unit_tests/test_marker_styles.py new file mode 100644 index 00000000..5a40e348 --- /dev/null +++ b/tests/unit_tests/test_marker_styles.py @@ -0,0 +1,133 @@ +import copy +from unittest import mock + +import pytest + +from goodmap.marker_styles import resolve_marker_styles + +# The literal URL the frontend's resolvePhosphorIconUrl.js builds for the same icon name +# (see frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx). Spelled out rather than +# imported from the module under test, so the two implementations drifting apart while +# the frontend shim is still in place shows up here. +PHOSPHOR_BRIDGE_URL = ( + "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" +) + + +def test_resolves_phosphor_entry_to_cdn_url(): + styles = {"icons": {"big bridge": {"provider": "phosphor", "value": "bridge"}}} + + assert resolve_marker_styles(styles)["icons"] == {"big bridge": PHOSPHOR_BRIDGE_URL} + + +def test_resolves_url_provider_entry_to_its_value(): + styles = {"icons": {"container": {"provider": "url", "value": "https://e.example/c.svg"}}} + + assert resolve_marker_styles(styles)["icons"] == {"container": "https://e.example/c.svg"} + + +def test_passes_plain_string_entry_through_unchanged(): + styles = {"icons": {"container": "https://e.example/c.svg"}} + + assert resolve_marker_styles(styles)["icons"] == {"container": "https://e.example/c.svg"} + + +@pytest.mark.parametrize( + "entry", + [ + {"provider": "phosphorr", "value": "bridge"}, + {"provider": None, "value": "bridge"}, + {"value": "bridge"}, + {"provider": "phosphor"}, + {"provider": "phosphor", "value": ""}, + {"provider": "phosphor", "value": 7}, + "", + 7, + None, + ["https://e.example/c.svg"], + ], + ids=[ + "unknown-provider", + "null-provider", + "no-provider", + "no-value", + "empty-value", + "non-string-value", + "empty-string", + "number", + "null", + "list", + ], +) +def test_unresolvable_entry_is_dropped_with_a_warning_naming_it(entry): + styles = {"icons": {"big bridge": entry}} + + with mock.patch("goodmap.marker_styles.logger") as mock_logger: + assert resolve_marker_styles(styles)["icons"] == {} + + mock_logger.warning.assert_called_once() + assert "big bridge" in mock_logger.warning.call_args[0][1:] + + +def test_one_bad_entry_does_not_drop_its_good_siblings(): + """A single typo costs that pin its icon, not every other pin's.""" + styles = { + "icons": { + "big bridge": {"provider": "phosphor", "value": "bridge"}, + "broken": {"provider": "nope", "value": "x"}, + "plain": "https://e.example/c.svg", + } + } + + assert resolve_marker_styles(styles)["icons"] == { + "big bridge": PHOSPHOR_BRIDGE_URL, + "plain": "https://e.example/c.svg", + } + + +def test_non_object_icons_resolves_to_nothing_rather_than_reaching_the_frontend(): + with mock.patch("goodmap.marker_styles.logger") as mock_logger: + assert resolve_marker_styles({"icons": "oops"})["icons"] == {} + + mock_logger.warning.assert_called_once() + + +def test_empty_marker_styles_stays_empty(): + assert resolve_marker_styles({}) == {} + + +def test_missing_icons_key_is_not_invented(): + assert resolve_marker_styles({"icon_field": "type_of_place"}) == {"icon_field": "type_of_place"} + + +def test_every_other_key_is_carried_through_untouched(): + """colors maps straight to CSS colors and never had a tagged form, so it - like the + two field names - must survive resolution unchanged.""" + styles = { + "icon_field": "type_of_place", + "color_field": "speed_limit", + "colors": {"10": "#2e7d32", "50": "#c62828"}, + "icons": {"plain": "https://e.example/c.svg"}, + } + + resolved = resolve_marker_styles(styles) + + assert resolved["icon_field"] == "type_of_place" + assert resolved["color_field"] == "speed_limit" + assert resolved["colors"] == {"10": "#2e7d32", "50": "#c62828"} + + +def test_does_not_mutate_the_config_it_was_given(): + """For the json backend this dict is the db's live in-memory config, so resolving in + place would rewrite what the deployment has stored.""" + styles = { + "icon_field": "type_of_place", + "icons": {"big bridge": {"provider": "phosphor", "value": "bridge"}}, + } + before = copy.deepcopy(styles) + icons_before = styles["icons"] + + resolve_marker_styles(styles) + + assert styles == before + assert styles["icons"] is icons_before From f51abbeb77e70b299dd5e41fc7f6216390239fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 19:38:35 +0200 Subject: [PATCH 37/45] update --- docs/data-source.rst | 7 +-- .../MarkerPopup/getTypedMarkerIcon.jsx | 43 ++++++------------- .../MarkerPopup/resolvePhosphorIconUrl.js | 15 ------- .../MarkerPopup/getTypedMarkerIcon.test.jsx | 42 ++++++------------ tests/unit_tests/test_marker_styles.py | 26 ++++++++++- 5 files changed, 55 insertions(+), 78 deletions(-) delete mode 100644 frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js diff --git a/docs/data-source.rst b/docs/data-source.rst index 1bada206..53c7acc6 100644 --- a/docs/data-source.rst +++ b/docs/data-source.rst @@ -298,9 +298,10 @@ a map with no ``marker_styles`` still renders, just with plain pins. `_ icon by name instead of hosting your own SVG. ``{"provider": "url", "value": "..."}`` is the plain string spelled out explicitly. - GoodMap turns these into plain URLs when the app starts, so the browser only ever - receives finished URLs. An entry it cannot make sense of — an unknown ``provider``, a - missing ``value`` — is logged as a warning at startup and left out, costing that one + ``phosphor`` and ``url`` are the providers GoodMap knows; each one's URL is built + server-side, so the browser only ever receives finished URLs and a new provider needs + no frontend release. An entry GoodMap cannot make sense of — an unknown ``provider``, + a missing ``value`` — is logged as a warning at startup and left out, costing that one pin its icon rather than breaking the map. ``colors`` diff --git a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx index fd6c1a11..88187789 100644 --- a/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx +++ b/frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import { DivIcon } from 'leaflet'; import ReactDOMServer from 'react-dom/server'; import PIN_SHAPE_URL from '../../res/svg/marker-pin.svg'; -import resolvePhosphorIconUrl from './resolvePhosphorIconUrl'; const PIN_WIDTH = 45; const PIN_HEIGHT = 50; @@ -16,34 +15,17 @@ const TYPE_ICON_OFFSET_TOP = 8; const TYPE_ICON_OFFSET_LEFT = 12; /** - * Resolves a configured marker_styles.icons entry to a usable URL. + * A configured marker_styles.icons entry, as a usable URL. * - * A current backend already resolves icons to plain URL strings (see goodmap's - * marker_styles.py), so that is the only branch this normally takes. The tagged - * {provider, value} branches remain as a compatibility shim, so this bundle also - * works against an older backend that still ships the unresolved form: provider - * "phosphor" builds a jsdelivr CDN URL for value (an icon name), see - * resolvePhosphorIconUrl; provider "url" is a plain string spelled out explicitly. - * They can go once such backends are no longer supported. + * Entries arrive already resolved: the backend turns whichever icon provider the + * deployment configured into a finished URL at startup (see goodmap's + * marker_styles.py), so a provider can be added there without this bundle changing. + * The type guard is just belt-and-braces against a non-string reaching the CSS. * - * @param {string|{provider: string, value: string}|undefined} icon - * @returns {string} A usable URL, or '' if icon is unset + * @param {string|undefined} icon + * @returns {string} The URL, or '' if unset or not a string */ -const resolveIconUrl = icon => { - if (!icon) { - return ''; - } - if (typeof icon === 'string') { - return icon; - } - if (icon.provider === 'phosphor') { - return resolvePhosphorIconUrl(icon.value); - } - if (icon.provider === 'url') { - return icon.value || ''; - } - return ''; -}; +const resolveIconUrl = icon => (typeof icon === 'string' ? icon : ''); /** * Own-property lookup in a config table. A point's field value is arbitrary data, @@ -127,14 +109,13 @@ PinIcon.propTypes = { /** * Builds a Leaflet icon for `place`: colored/typed from the deployment's * marker styling lookup table (window.MARKER_STYLES, see goodmap's - * db.get_marker_styles) when `place.marker`'s icon/color match an entry, - * our own pin in the fallback color with just the asterisk badge when + * marker_styles.resolve_marker_styles) when `place.marker`'s icon/color match an + * entry, our own pin in the fallback color with just the asterisk badge when * `place.marker.badge` is set but nothing matched, or `null` (falls back to * Leaflet's default marker) when there's neither a match nor a badge to show. * - * Each entry in MARKER_STYLES.icons is either a plain URL string, or a tagged - * {provider: "phosphor", value: ""} / {provider: "url", value: ""} - * object - see resolveIconUrl. + * MARKER_STYLES.icons maps a value to a plain URL string; whichever icon provider + * the deployment configured was already resolved away server-side. * * @param {Object} place - Location data, as returned by GET /api/locations * @param {Object} [place.marker] - Pin styling: {icon, color, badge} diff --git a/frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js b/frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js deleted file mode 100644 index a3d7c1b4..00000000 --- a/frontend/src/components/MarkerPopup/resolvePhosphorIconUrl.js +++ /dev/null @@ -1,15 +0,0 @@ -const PHOSPHOR_ICONS_CDN_BASE = 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill'; - -/** - * Resolves a phosphor icon name (kebab-case, e.g. "shipping-container") to its - * "fill" weight SVG URL on the Phosphor Icons CDN (MIT-licensed, jsdelivr-hosted). - * Not validated against the actual icon set - an unknown name just 404s in the - * browser when the mask-image is requested, same as any other misconfigured URL. - * - * @param {string} name - Icon name, matching a filename in - * @phosphor-icons/core/assets/fill without its "-fill.svg" suffix - * @returns {string} The icon's CDN URL - */ -const resolvePhosphorIconUrl = name => `${PHOSPHOR_ICONS_CDN_BASE}/${name}-fill.svg`; - -export default resolvePhosphorIconUrl; diff --git a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx index d7649855..ca4c928c 100644 --- a/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx +++ b/frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx @@ -194,25 +194,12 @@ describe('getTypedMarkerIcon icon value shapes', () => { delete globalThis.MARKER_STYLES; }); - it('resolves a {provider: "phosphor", value} entry to the jsdelivr CDN URL for that icon', () => { + // The backend resolves whichever icon provider the deployment configured into a + // finished URL before it ever reaches window.MARKER_STYLES (see goodmap's + // marker_styles.py), so a plain URL string is the only shape this sees. + it('uses an icon entry as a direct URL', () => { setMarkerStyles(`{ - "icons": { "container": { "provider": "phosphor", "value": "shipping-container" } } - }`); - - const icon = getTypedMarkerIcon({ - uuid: '1', - position: [50, 50], - marker: { icon: 'container' }, - }); - - expect(icon.options.html).toContain( - 'mask-image:url(https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/shipping-container-fill.svg)', - ); - }); - - it('resolves a {provider: "url", value} entry as a plain URL, without touching phosphor', () => { - setMarkerStyles(`{ - "icons": { "container": { "provider": "url", "value": "https://cdn.example.com/c.svg" } } + "icons": { "container": "https://cdn.example.com/c.svg" } }`); const icon = getTypedMarkerIcon({ @@ -222,20 +209,19 @@ describe('getTypedMarkerIcon icon value shapes', () => { }); expect(icon.options.html).toContain('mask-image:url(https://cdn.example.com/c.svg)'); - expect(icon.options.html).not.toContain('jsdelivr'); }); - it('still accepts a plain string entry as a direct URL, unchanged from before', () => { + it('ignores a non-string icon entry rather than putting it in the CSS', () => { setMarkerStyles(`{ - "icons": { "container": "https://cdn.example.com/c.svg" } + "icons": { "container": { "provider": "phosphor", "value": "shipping-container" } } }`); - const icon = getTypedMarkerIcon({ - uuid: '1', - position: [50, 50], - marker: { icon: 'container' }, - }); - - expect(icon.options.html).toContain('mask-image:url(https://cdn.example.com/c.svg)'); + expect( + getTypedMarkerIcon({ + uuid: '1', + position: [50, 50], + marker: { icon: 'container' }, + }), + ).toBeNull(); }); }); diff --git a/tests/unit_tests/test_marker_styles.py b/tests/unit_tests/test_marker_styles.py index 5a40e348..10ea10b1 100644 --- a/tests/unit_tests/test_marker_styles.py +++ b/tests/unit_tests/test_marker_styles.py @@ -3,7 +3,7 @@ import pytest -from goodmap.marker_styles import resolve_marker_styles +from goodmap.marker_styles import ICON_PROVIDERS, PhosphorIconProvider, resolve_marker_styles # The literal URL the frontend's resolvePhosphorIconUrl.js builds for the same icon name # (see frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx). Spelled out rather than @@ -14,6 +14,30 @@ ) +def test_phosphor_provider_builds_the_whole_cdn_url_from_an_icon_name(): + """Pins the provider itself, independently of the resolution plumbing around it - + this is the URL the frontend used to build for itself.""" + assert PhosphorIconProvider().resolve("bridge") == PHOSPHOR_BRIDGE_URL + + +def test_a_provider_added_to_the_registry_is_picked_up(): + """The registry's whole point: a new provider is a class plus a dict entry, with no + edit to the resolution path.""" + + class SpriteProvider: + def resolve(self, value): + return f"https://sprites.example/{value}.svg" + + styles = {"icons": {"big bridge": {"provider": "sprite", "value": "bridge"}}} + + with mock.patch.dict(ICON_PROVIDERS, {"sprite": SpriteProvider()}): + resolved = resolve_marker_styles(styles) + + assert resolved["icons"] == {"big bridge": "https://sprites.example/bridge.svg"} + # ...and it is gone again once unregistered, so the patch really was what mattered. + assert resolve_marker_styles(styles)["icons"] == {} + + def test_resolves_phosphor_entry_to_cdn_url(): styles = {"icons": {"big bridge": {"provider": "phosphor", "value": "bridge"}}} From c4c882136df4b042d193edc4a54f4d8c3a8a1849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 22:34:52 +0200 Subject: [PATCH 38/45] missing file --- goodmap/marker_styles.py | 137 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 goodmap/marker_styles.py diff --git a/goodmap/marker_styles.py b/goodmap/marker_styles.py new file mode 100644 index 00000000..6d32c468 --- /dev/null +++ b/goodmap/marker_styles.py @@ -0,0 +1,137 @@ +"""Resolves configured marker_styles.icons entries into plain, browser-ready URLs. + +Runs once at app startup (see goodmap.create_app_from_config), turning the tagged +``{"provider": ..., "value": ...}`` form a data source may use into flat +``{icon_field_value: url}`` entries. window.MARKER_STYLES.icons is therefore always a +plain lookup table, so supporting a new provider needs no frontend release - the +separately versioned frontend bundle only ever has to understand URL strings. +""" + +import logging +from typing import Any, Protocol + +logger = logging.getLogger(__name__) + + +class IconProvider(Protocol): + """Turns one configured icon ``value`` into a browser-ready URL. + + A protocol rather than a base class: a provider is only ever looked up by name in + ICON_PROVIDERS, so there is nothing to gain from making implementers inherit from us. + """ + + def resolve(self, value: str) -> str: + """Build the URL this provider serves for ``value``.""" + ... + + +class PhosphorIconProvider: + """Phosphor Icons (MIT), served from jsdelivr. + + ``value`` is an icon name in kebab-case, matching a filename in + ``@phosphor-icons/core/assets/`` without its ``-.svg`` suffix, e.g. + "shipping-container". Not validated against the actual icon set - an unknown name + just 404s in the browser, the same as any other mistyped URL. + """ + + CDN_BASE = "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets" + WEIGHT = "fill" + + def resolve(self, value: str) -> str: + """Build the CDN URL for the Phosphor icon named ``value``.""" + return f"{self.CDN_BASE}/{self.WEIGHT}/{value}-{self.WEIGHT}.svg" + + +class DirectUrlProvider: + """A URL the deployment hosts itself, used exactly as configured.""" + + def resolve(self, value: str) -> str: + """Return ``value`` unchanged - it is already a URL.""" + return value + + +# Every icon provider a data source may name. Adding one is a class plus an entry here; +# nothing downstream - and no frontend release - has to know about it, because what +# reaches the browser is always a finished URL. +ICON_PROVIDERS: dict[str, IconProvider] = { + "phosphor": PhosphorIconProvider(), + "url": DirectUrlProvider(), +} + +# A bare string entry is shorthand for this provider, so both spellings resolve by the +# same path rather than one of them short-circuiting. +_SHORTHAND_PROVIDER = "url" + + +def _resolve_icon_entry(key: Any, entry: Any) -> str | None: + """Resolve one marker_styles.icons entry to a usable URL. + + Args: + key: The icons key this entry sits under, used only to name it in the warning + logged when the entry cannot be resolved. + entry: The raw entry - a plain URL string, a tagged + {"provider": , "value": str} dict, or malformed data. + + Returns: + The resolved URL, or None if the entry is unresolvable (already logged). + """ + if isinstance(entry, str): + name, value = _SHORTHAND_PROVIDER, entry + elif isinstance(entry, dict): + name, value = entry.get("provider"), entry.get("value") + else: + logger.warning( + "marker_styles.icons['%s'] is neither a URL string nor a {provider, value} " + "object; ignoring it", + key, + ) + return None + + if not isinstance(value, str) or not value: + logger.warning("marker_styles.icons['%s'] has no usable 'value'; ignoring it", key) + return None + + provider = ICON_PROVIDERS.get(name) if isinstance(name, str) else None + if provider is None: + logger.warning("marker_styles.icons['%s'] has unknown provider %r; ignoring it", key, name) + return None + + return provider.resolve(value) + + +def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: + """Resolve marker_styles.icons into a flat {value: url} lookup table. + + An entry that cannot be resolved is dropped with a warning naming it, rather than + aborting startup: a typo in one icon costs that pin its icon, not the whole map. + + Args: + marker_styles: Raw marker_styles config as returned by + goodmap.db.get_marker_styles(). May be empty or lack an "icons" key. Never + mutated - for the json backend this is the db's live in-memory config, so + resolving in place would rewrite what the deployment has stored. + + Returns: + A new dict. "icons", if present, is replaced by a flat {value: url} map with + unresolvable entries omitted; every other key (icon_field, color_field, colors) + is carried through untouched. "colors" needs no resolving - it maps straight to + CSS colors and never had a tagged form. + """ + resolved = dict(marker_styles) + icons = marker_styles.get("icons") + + if icons is None: + return resolved + + if not isinstance(icons, dict): + logger.warning("marker_styles.icons is not an object; ignoring it entirely") + resolved["icons"] = {} + return resolved + + resolved_icons: dict[Any, str] = {} + for key, entry in icons.items(): + url = _resolve_icon_entry(key, entry) + if url is not None: + resolved_icons[key] = url + resolved["icons"] = resolved_icons + return resolved From c31459eeb3f9db8a954574157dd9dd93e82613c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 23:40:06 +0200 Subject: [PATCH 39/45] less comments --- goodmap/templates/map.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/goodmap/templates/map.html b/goodmap/templates/map.html index fb18e967..def510eb 100644 --- a/goodmap/templates/map.html +++ b/goodmap/templates/map.html @@ -120,9 +120,6 @@ window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }}; window.FEATURE_FLAGS = {{ feature_flags | tojson }}; window.PLUGIN_MANIFEST = {{ plugin_manifest | tojson }}; -// Deployment-specific pin icon/color lookup table. icons are already resolved to plain -// URLs here - see goodmap/marker_styles.py's resolve_marker_styles; the raw stored -// config (which may use {provider, value}) comes from goodmap/db.py's get_marker_styles. window.MARKER_STYLES = {{ marker_styles | tojson }}; From 08a870b1740bc9138378be6a374b41061952cb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 23:47:40 +0200 Subject: [PATCH 40/45] less comments --- goodmap/goodmap.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index d4acbb0b..27df0f62 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -185,12 +185,6 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: app.db = extend_db_with_goodmap_queries(app.db, location_model) obligatory_field_names = {name for name, _ in location_obligatory_fields} - # pin_marker_fields is app-wiring knowledge - which of this deployment's fields - # marker_styles.icon_field/color_field actually point at - not something the - # location model itself needs to know; threaded to core_pages() for - # goodmap.api.api_models.marker_style_values() to use. A configured field that - # isn't actually an obligatory field of this deployment's locations is dropped - # rather than trusted blindly. pin_marker_fields = PinMarkerFields( icon_field=( marker_styles.get("icon_field") From 954c92497758c2fc7ae424e4dfbfe4386bff44d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 21 Aug 2026 23:58:17 +0200 Subject: [PATCH 41/45] less comments --- goodmap/goodmap.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 27df0f62..690ca25c 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -282,10 +282,6 @@ def index(): Returns: Rendered map.html template with feature flags and the plugin manifest """ - # The startup-time marker_styles, not a fresh read: pin_marker_fields (which - # decides what /api/locations puts in marker.icon/color) is frozen at startup - # too, so re-reading here would hand the frontend lookup tables keyed on a - # field the API is no longer sending values from. return render_template( "map.html", feature_flags=config.feature_flags, From fe9925f454ac1913859d46a32085c2f199d70716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 22 Aug 2026 00:11:25 +0200 Subject: [PATCH 42/45] little simplification --- goodmap/marker_styles.py | 33 +++++++++++++------------- tests/unit_tests/test_marker_styles.py | 2 ++ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/goodmap/marker_styles.py b/goodmap/marker_styles.py index 6d32c468..785ea038 100644 --- a/goodmap/marker_styles.py +++ b/goodmap/marker_styles.py @@ -58,10 +58,6 @@ def resolve(self, value: str) -> str: "url": DirectUrlProvider(), } -# A bare string entry is shorthand for this provider, so both spellings resolve by the -# same path rather than one of them short-circuiting. -_SHORTHAND_PROVIDER = "url" - def _resolve_icon_entry(key: Any, entry: Any) -> str | None: """Resolve one marker_styles.icons entry to a usable URL. @@ -76,7 +72,9 @@ def _resolve_icon_entry(key: Any, entry: Any) -> str | None: The resolved URL, or None if the entry is unresolvable (already logged). """ if isinstance(entry, str): - name, value = _SHORTHAND_PROVIDER, entry + # A bare string is shorthand for the "url" provider, so both spellings resolve + # by the same path rather than one of them short-circuiting. + name, value = "url", entry elif isinstance(entry, dict): name, value = entry.get("provider"), entry.get("value") else: @@ -91,6 +89,8 @@ def _resolve_icon_entry(key: Any, entry: Any) -> str | None: logger.warning("marker_styles.icons['%s'] has no usable 'value'; ignoring it", key) return None + # The isinstance check comes first because an unhashable provider - a list, say - + # would make .get() raise TypeError rather than miss. provider = ICON_PROVIDERS.get(name) if isinstance(name, str) else None if provider is None: logger.warning("marker_styles.icons['%s'] has unknown provider %r; ignoring it", key, name) @@ -117,21 +117,20 @@ def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: is carried through untouched. "colors" needs no resolving - it maps straight to CSS colors and never had a tagged form. """ - resolved = dict(marker_styles) icons = marker_styles.get("icons") if icons is None: - return resolved + return dict(marker_styles) if not isinstance(icons, dict): logger.warning("marker_styles.icons is not an object; ignoring it entirely") - resolved["icons"] = {} - return resolved - - resolved_icons: dict[Any, str] = {} - for key, entry in icons.items(): - url = _resolve_icon_entry(key, entry) - if url is not None: - resolved_icons[key] = url - resolved["icons"] = resolved_icons - return resolved + return {**marker_styles, "icons": {}} + + return { + **marker_styles, + "icons": { + key: url + for key, entry in icons.items() + if (url := _resolve_icon_entry(key, entry)) is not None + }, + } diff --git a/tests/unit_tests/test_marker_styles.py b/tests/unit_tests/test_marker_styles.py index 10ea10b1..62f308aa 100644 --- a/tests/unit_tests/test_marker_styles.py +++ b/tests/unit_tests/test_marker_styles.py @@ -65,6 +65,7 @@ def test_passes_plain_string_entry_through_unchanged(): {"provider": "phosphor"}, {"provider": "phosphor", "value": ""}, {"provider": "phosphor", "value": 7}, + {"provider": ["phosphor"], "value": "bridge"}, "", 7, None, @@ -77,6 +78,7 @@ def test_passes_plain_string_entry_through_unchanged(): "no-value", "empty-value", "non-string-value", + "unhashable-provider", "empty-string", "number", "null", From db5251620f839a30bcfd5567af58231f4d9b2804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 22 Aug 2026 00:19:15 +0200 Subject: [PATCH 43/45] simplify --- docs/data-source.rst | 4 +- goodmap/marker_styles.py | 76 +++++++------------------- tests/unit_tests/test_marker_styles.py | 69 ++++------------------- 3 files changed, 34 insertions(+), 115 deletions(-) diff --git a/docs/data-source.rst b/docs/data-source.rst index 53c7acc6..2d308769 100644 --- a/docs/data-source.rst +++ b/docs/data-source.rst @@ -301,8 +301,8 @@ a map with no ``marker_styles`` still renders, just with plain pins. ``phosphor`` and ``url`` are the providers GoodMap knows; each one's URL is built server-side, so the browser only ever receives finished URLs and a new provider needs no frontend release. An entry GoodMap cannot make sense of — an unknown ``provider``, - a missing ``value`` — is logged as a warning at startup and left out, costing that one - pin its icon rather than breaking the map. + a missing ``value`` — stops the app from starting, so the mistake surfaces on deploy + rather than as a silently unstyled pin. ``colors`` Maps a value of ``color_field`` to a CSS color. diff --git a/goodmap/marker_styles.py b/goodmap/marker_styles.py index 785ea038..9154e3dc 100644 --- a/goodmap/marker_styles.py +++ b/goodmap/marker_styles.py @@ -7,11 +7,8 @@ separately versioned frontend bundle only ever has to understand URL strings. """ -import logging from typing import Any, Protocol -logger = logging.getLogger(__name__) - class IconProvider(Protocol): """Turns one configured icon ``value`` into a browser-ready URL. @@ -59,52 +56,29 @@ def resolve(self, value: str) -> str: } -def _resolve_icon_entry(key: Any, entry: Any) -> str | None: - """Resolve one marker_styles.icons entry to a usable URL. +def _icon_url(entry: Any) -> str: + """The URL one marker_styles.icons entry stands for. Args: - key: The icons key this entry sits under, used only to name it in the warning - logged when the entry cannot be resolved. - entry: The raw entry - a plain URL string, a tagged - {"provider": , "value": str} dict, or malformed data. + entry: A plain URL string - shorthand for the "url" provider - or a tagged + {"provider": , "value": str} dict. Returns: - The resolved URL, or None if the entry is unresolvable (already logged). + The resolved URL. + + Raises: + KeyError, TypeError: The entry is malformed. Deliberately not caught: bad + marker_styles config stops the app from starting, the same way a category + with no allowed values does (see data_models.location.create_location_model). """ if isinstance(entry, str): - # A bare string is shorthand for the "url" provider, so both spellings resolve - # by the same path rather than one of them short-circuiting. - name, value = "url", entry - elif isinstance(entry, dict): - name, value = entry.get("provider"), entry.get("value") - else: - logger.warning( - "marker_styles.icons['%s'] is neither a URL string nor a {provider, value} " - "object; ignoring it", - key, - ) - return None - - if not isinstance(value, str) or not value: - logger.warning("marker_styles.icons['%s'] has no usable 'value'; ignoring it", key) - return None - - # The isinstance check comes first because an unhashable provider - a list, say - - # would make .get() raise TypeError rather than miss. - provider = ICON_PROVIDERS.get(name) if isinstance(name, str) else None - if provider is None: - logger.warning("marker_styles.icons['%s'] has unknown provider %r; ignoring it", key, name) - return None - - return provider.resolve(value) + return ICON_PROVIDERS["url"].resolve(entry) + return ICON_PROVIDERS[entry["provider"]].resolve(entry["value"]) def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: """Resolve marker_styles.icons into a flat {value: url} lookup table. - An entry that cannot be resolved is dropped with a warning naming it, rather than - aborting startup: a typo in one icon costs that pin its icon, not the whole map. - Args: marker_styles: Raw marker_styles config as returned by goodmap.db.get_marker_styles(). May be empty or lack an "icons" key. Never @@ -112,25 +86,17 @@ def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: resolving in place would rewrite what the deployment has stored. Returns: - A new dict. "icons", if present, is replaced by a flat {value: url} map with - unresolvable entries omitted; every other key (icon_field, color_field, colors) - is carried through untouched. "colors" needs no resolving - it maps straight to - CSS colors and never had a tagged form. + A new dict. "icons", if present, is replaced by a flat {value: url} map; every + other key (icon_field, color_field, colors) is carried through untouched. + "colors" needs no resolving - it maps straight to CSS colors and never had a + tagged form. + + Raises: + AttributeError, KeyError, TypeError: marker_styles.icons is malformed; see + _icon_url. Uncaught by design, so the app refuses to start. """ icons = marker_styles.get("icons") - if icons is None: return dict(marker_styles) - if not isinstance(icons, dict): - logger.warning("marker_styles.icons is not an object; ignoring it entirely") - return {**marker_styles, "icons": {}} - - return { - **marker_styles, - "icons": { - key: url - for key, entry in icons.items() - if (url := _resolve_icon_entry(key, entry)) is not None - }, - } + return {**marker_styles, "icons": {key: _icon_url(entry) for key, entry in icons.items()}} diff --git a/tests/unit_tests/test_marker_styles.py b/tests/unit_tests/test_marker_styles.py index 62f308aa..fe7fce6a 100644 --- a/tests/unit_tests/test_marker_styles.py +++ b/tests/unit_tests/test_marker_styles.py @@ -5,18 +5,16 @@ from goodmap.marker_styles import ICON_PROVIDERS, PhosphorIconProvider, resolve_marker_styles -# The literal URL the frontend's resolvePhosphorIconUrl.js builds for the same icon name -# (see frontend/tests/MarkerPopup/getTypedMarkerIcon.test.jsx). Spelled out rather than -# imported from the module under test, so the two implementations drifting apart while -# the frontend shim is still in place shows up here. +# The URL the frontend used to build for itself before resolution moved server-side. +# Spelled out rather than imported from the module under test, so a change to how it is +# assembled has to be made deliberately here too. PHOSPHOR_BRIDGE_URL = ( "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" ) def test_phosphor_provider_builds_the_whole_cdn_url_from_an_icon_name(): - """Pins the provider itself, independently of the resolution plumbing around it - - this is the URL the frontend used to build for itself.""" + """Pins the provider itself, independently of the resolution plumbing around it.""" assert PhosphorIconProvider().resolve("bridge") == PHOSPHOR_BRIDGE_URL @@ -34,8 +32,6 @@ def resolve(self, value): resolved = resolve_marker_styles(styles) assert resolved["icons"] == {"big bridge": "https://sprites.example/bridge.svg"} - # ...and it is gone again once unregistered, so the patch really was what mattered. - assert resolve_marker_styles(styles)["icons"] == {} def test_resolves_phosphor_entry_to_cdn_url(): @@ -60,62 +56,19 @@ def test_passes_plain_string_entry_through_unchanged(): "entry", [ {"provider": "phosphorr", "value": "bridge"}, - {"provider": None, "value": "bridge"}, {"value": "bridge"}, {"provider": "phosphor"}, - {"provider": "phosphor", "value": ""}, - {"provider": "phosphor", "value": 7}, - {"provider": ["phosphor"], "value": "bridge"}, - "", 7, None, - ["https://e.example/c.svg"], - ], - ids=[ - "unknown-provider", - "null-provider", - "no-provider", - "no-value", - "empty-value", - "non-string-value", - "unhashable-provider", - "empty-string", - "number", - "null", - "list", ], + ids=["unknown-provider", "no-provider", "no-value", "number", "null"], ) -def test_unresolvable_entry_is_dropped_with_a_warning_naming_it(entry): - styles = {"icons": {"big bridge": entry}} - - with mock.patch("goodmap.marker_styles.logger") as mock_logger: - assert resolve_marker_styles(styles)["icons"] == {} - - mock_logger.warning.assert_called_once() - assert "big bridge" in mock_logger.warning.call_args[0][1:] - - -def test_one_bad_entry_does_not_drop_its_good_siblings(): - """A single typo costs that pin its icon, not every other pin's.""" - styles = { - "icons": { - "big bridge": {"provider": "phosphor", "value": "bridge"}, - "broken": {"provider": "nope", "value": "x"}, - "plain": "https://e.example/c.svg", - } - } - - assert resolve_marker_styles(styles)["icons"] == { - "big bridge": PHOSPHOR_BRIDGE_URL, - "plain": "https://e.example/c.svg", - } - - -def test_non_object_icons_resolves_to_nothing_rather_than_reaching_the_frontend(): - with mock.patch("goodmap.marker_styles.logger") as mock_logger: - assert resolve_marker_styles({"icons": "oops"})["icons"] == {} - - mock_logger.warning.assert_called_once() +def test_malformed_entry_stops_the_app_from_starting(entry): + """Bad marker_styles config is a deploy-time mistake, so it raises rather than + quietly costing a pin its icon - the same stance create_location_model takes on a + category with no allowed values.""" + with pytest.raises((KeyError, TypeError, AttributeError)): + resolve_marker_styles({"icons": {"big bridge": entry}}) def test_empty_marker_styles_stays_empty(): From 092ace90a5fd7355aeb8b959127dd9aaf4e3dfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 22 Aug 2026 00:37:37 +0200 Subject: [PATCH 44/45] less code --- docs/data-source.rst | 32 ++++++++----- e2e-tests/e2e_test_data_initial.json | 11 ++--- goodmap/marker_styles.py | 50 +++++++------------- tests/unit_tests/test_goodmap.py | 28 ++++++----- tests/unit_tests/test_marker_styles.py | 65 ++++++++++++++------------ 5 files changed, 92 insertions(+), 94 deletions(-) diff --git a/docs/data-source.rst b/docs/data-source.rst index 2d308769..f07937e1 100644 --- a/docs/data-source.rst +++ b/docs/data-source.rst @@ -272,9 +272,10 @@ a map with no ``marker_styles`` still renders, just with plain pins. "marker_styles": { "icon_field": "type_of_place", "color_field": "transparency", + "icon_provider": "phosphor", "icons": { - "big bridge": "https://cdn.example.com/bridge.svg", - "container": {"provider": "phosphor", "value": "shipping-container"} + "big bridge": "bridge", + "container": "shipping-container" }, "colors": { "lacking": "#c62828", @@ -292,17 +293,24 @@ a map with no ``marker_styles`` still renders, just with plain pins. only some of your points carry. A name that isn't declared there is ignored, and pins get no icon/color from it. +``icon_provider`` + Where your icons come from. One provider serves the whole ``icons`` table: + + ``phosphor`` + Entries are `Phosphor `_ icon names in kebab-case + (e.g. ``"shipping-container"``), so you need not host SVGs yourself. + ``url`` + Entries are URLs of SVGs you host. + + Required whenever ``icons`` has anything in it. Naming a provider GoodMap does not + know stops the app from starting, so the mistake surfaces on deploy rather than as a + silently unstyled pin. + ``icons`` - Maps a value of ``icon_field`` to either a plain URL string, or - ``{"provider": "phosphor", "value": ""}`` to use a `Phosphor - `_ icon by name instead of hosting your own SVG. - ``{"provider": "url", "value": "..."}`` is the plain string spelled out explicitly. - - ``phosphor`` and ``url`` are the providers GoodMap knows; each one's URL is built - server-side, so the browser only ever receives finished URLs and a new provider needs - no frontend release. An entry GoodMap cannot make sense of — an unknown ``provider``, - a missing ``value`` — stops the app from starting, so the mistake surfaces on deploy - rather than as a silently unstyled pin. + Maps a value of ``icon_field`` to whatever ``icon_provider`` takes — an icon name for + ``phosphor``, a URL for ``url``. GoodMap turns these into finished URLs when the app + starts, so the browser never sees the provider and adding a new one needs no frontend + release. ``colors`` Maps a value of ``color_field`` to a CSS color. diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index 0f4e7474..7468d18c 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -276,15 +276,10 @@ "marker_styles": { "icon_field": "type_of_place", "color_field": "speed_limit", + "icon_provider": "phosphor", "icons": { - "big bridge": { - "provider": "phosphor", - "value": "bridge" - }, - "small bridge": { - "provider": "phosphor", - "value": "footprints" - } + "big bridge": "bridge", + "small bridge": "footprints" }, "colors": { "10": "#2e7d32", diff --git a/goodmap/marker_styles.py b/goodmap/marker_styles.py index 9154e3dc..023196d4 100644 --- a/goodmap/marker_styles.py +++ b/goodmap/marker_styles.py @@ -56,47 +56,33 @@ def resolve(self, value: str) -> str: } -def _icon_url(entry: Any) -> str: - """The URL one marker_styles.icons entry stands for. - - Args: - entry: A plain URL string - shorthand for the "url" provider - or a tagged - {"provider": , "value": str} dict. - - Returns: - The resolved URL. - - Raises: - KeyError, TypeError: The entry is malformed. Deliberately not caught: bad - marker_styles config stops the app from starting, the same way a category - with no allowed values does (see data_models.location.create_location_model). - """ - if isinstance(entry, str): - return ICON_PROVIDERS["url"].resolve(entry) - return ICON_PROVIDERS[entry["provider"]].resolve(entry["value"]) - - def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: """Resolve marker_styles.icons into a flat {value: url} lookup table. + One "icon_provider" serves the whole table, so every entry is a plain value that + provider understands - a Phosphor icon name, a URL - rather than each one restating + which provider it came from. + Args: marker_styles: Raw marker_styles config as returned by - goodmap.db.get_marker_styles(). May be empty or lack an "icons" key. Never - mutated - for the json backend this is the db's live in-memory config, so - resolving in place would rewrite what the deployment has stored. + goodmap.db.get_marker_styles(). Carries "icon_provider" (a name in + ICON_PROVIDERS) whenever it carries "icons". May be empty or lack both. + Never mutated - for the json backend this is the db's live in-memory config, + so resolving in place would rewrite what is stored. Returns: A new dict. "icons", if present, is replaced by a flat {value: url} map; every other key (icon_field, color_field, colors) is carried through untouched. - "colors" needs no resolving - it maps straight to CSS colors and never had a - tagged form. + "colors" needs no resolving - it maps straight to CSS colors, with no provider. Raises: - AttributeError, KeyError, TypeError: marker_styles.icons is malformed; see - _icon_url. Uncaught by design, so the app refuses to start. + KeyError, TypeError: "icon_provider" is missing or names a provider that does + not exist. Uncaught by design: bad config stops the app from starting, the + same way a category with no allowed values does (see + data_models.location.create_location_model). """ - icons = marker_styles.get("icons") - if icons is None: - return dict(marker_styles) - - return {**marker_styles, "icons": {key: _icon_url(entry) for key, entry in icons.items()}} + resolved = dict(marker_styles) + if icons := marker_styles.get("icons"): + provider = ICON_PROVIDERS[marker_styles["icon_provider"]] + resolved["icons"] = {key: provider.resolve(value) for key, value in icons.items()} + return resolved diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index ce5e37ac..7807a30f 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -1,6 +1,8 @@ import importlib.metadata import io +import json import os +import re import sys import tempfile import types @@ -132,9 +134,8 @@ def test_map_route_marker_styles(): "categories": {"type_of_place": ["parcel_locker", "container"]}, "marker_styles": { "icon_field": "type_of_place", - "icons": { - "parcel_locker": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/package-fill.svg" - }, + "icon_provider": "phosphor", + "icons": {"parcel_locker": "package"}, "colors": {}, }, }, @@ -170,6 +171,7 @@ def test_map_route_marker_styles_stay_in_step_with_the_api(): "location_obligatory_fields": [["type_of_place", "str"]], "marker_styles": { "icon_field": "type_of_place", + "icon_provider": "url", "icons": {"parcel_locker": "https://cdn.example.com/package.svg"}, "colors": {}, }, @@ -193,15 +195,16 @@ def test_map_route_marker_styles_stay_in_step_with_the_api(): def test_map_route_serves_icons_already_resolved_to_urls(): - """window.MARKER_STYLES.icons is a flat {value: url} table: the tagged - {provider, value} form a data source may use is resolved at startup, so supporting a - new provider never needs a frontend release.""" + """window.MARKER_STYLES.icons is a flat {value: url} table: the provider-specific + values a data source configures are resolved at startup, so supporting a new provider + never needs a frontend release.""" data = { "site_content": {"pages": []}, "location_obligatory_fields": [["type_of_place", "str"]], "marker_styles": { "icon_field": "type_of_place", - "icons": {"big bridge": {"provider": "phosphor", "value": "bridge"}}, + "icon_provider": "phosphor", + "icons": {"big bridge": "bridge"}, "colors": {}, }, } @@ -218,11 +221,12 @@ def test_map_route_serves_icons_already_resolved_to_urls(): response_text = app.test_client().get("/map").data.decode("utf-8") - assert ( - "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" - in response_text - ) - assert "provider" not in response_text + match = re.search(r"window\.MARKER_STYLES\s*=\s*(.*?);", response_text) + assert match is not None + served = json.loads(match.group(1)) + assert served["icons"] == { + "big bridge": "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/bridge-fill.svg" + } def test_map_route_includes_photo_constraints(): diff --git a/tests/unit_tests/test_marker_styles.py b/tests/unit_tests/test_marker_styles.py index fe7fce6a..a6b93dfa 100644 --- a/tests/unit_tests/test_marker_styles.py +++ b/tests/unit_tests/test_marker_styles.py @@ -26,7 +26,7 @@ class SpriteProvider: def resolve(self, value): return f"https://sprites.example/{value}.svg" - styles = {"icons": {"big bridge": {"provider": "sprite", "value": "bridge"}}} + styles = {"icon_provider": "sprite", "icons": {"big bridge": "bridge"}} with mock.patch.dict(ICON_PROVIDERS, {"sprite": SpriteProvider()}): resolved = resolve_marker_styles(styles) @@ -34,58 +34,66 @@ def resolve(self, value): assert resolved["icons"] == {"big bridge": "https://sprites.example/bridge.svg"} -def test_resolves_phosphor_entry_to_cdn_url(): - styles = {"icons": {"big bridge": {"provider": "phosphor", "value": "bridge"}}} - - assert resolve_marker_styles(styles)["icons"] == {"big bridge": PHOSPHOR_BRIDGE_URL} - - -def test_resolves_url_provider_entry_to_its_value(): - styles = {"icons": {"container": {"provider": "url", "value": "https://e.example/c.svg"}}} +def test_phosphor_provider_resolves_every_entry_in_the_table(): + styles = { + "icon_provider": "phosphor", + "icons": {"big bridge": "bridge", "small bridge": "footprints"}, + } - assert resolve_marker_styles(styles)["icons"] == {"container": "https://e.example/c.svg"} + assert resolve_marker_styles(styles)["icons"] == { + "big bridge": PHOSPHOR_BRIDGE_URL, + "small bridge": ( + "https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/fill/footprints-fill.svg" + ), + } -def test_passes_plain_string_entry_through_unchanged(): - styles = {"icons": {"container": "https://e.example/c.svg"}} +def test_url_provider_serves_entries_the_deployment_hosts_itself(): + styles = {"icon_provider": "url", "icons": {"container": "https://e.example/c.svg"}} assert resolve_marker_styles(styles)["icons"] == {"container": "https://e.example/c.svg"} @pytest.mark.parametrize( - "entry", + "styles", [ - {"provider": "phosphorr", "value": "bridge"}, - {"value": "bridge"}, - {"provider": "phosphor"}, - 7, - None, + {"icon_provider": "phosphorr", "icons": {"big bridge": "bridge"}}, + {"icons": {"big bridge": "bridge"}}, + {"icon_provider": None, "icons": {"big bridge": "bridge"}}, ], - ids=["unknown-provider", "no-provider", "no-value", "number", "null"], + ids=["unknown-provider", "no-provider", "null-provider"], ) -def test_malformed_entry_stops_the_app_from_starting(entry): +def test_malformed_config_stops_the_app_from_starting(styles): """Bad marker_styles config is a deploy-time mistake, so it raises rather than quietly costing a pin its icon - the same stance create_location_model takes on a category with no allowed values.""" - with pytest.raises((KeyError, TypeError, AttributeError)): - resolve_marker_styles({"icons": {"big bridge": entry}}) + with pytest.raises((KeyError, TypeError)): + resolve_marker_styles(styles) def test_empty_marker_styles_stays_empty(): assert resolve_marker_styles({}) == {} -def test_missing_icons_key_is_not_invented(): - assert resolve_marker_styles({"icon_field": "type_of_place"}) == {"icon_field": "type_of_place"} +@pytest.mark.parametrize("icons", [{}, None], ids=["empty-table", "no-icons-key"]) +def test_nothing_to_resolve_needs_no_provider(icons): + """A deployment that styles pins by color alone never names an icon provider, so an + absent or empty table must not demand one.""" + styles = {"icon_field": "type_of_place", "colors": {"10": "#2e7d32"}} + if icons is not None: + styles["icons"] = icons + + assert resolve_marker_styles(styles) == styles def test_every_other_key_is_carried_through_untouched(): - """colors maps straight to CSS colors and never had a tagged form, so it - like the - two field names - must survive resolution unchanged.""" + """colors maps straight to CSS colors and has no provider, so it - like the two field + names - must survive resolution unchanged.""" styles = { "icon_field": "type_of_place", "color_field": "speed_limit", "colors": {"10": "#2e7d32", "50": "#c62828"}, + "icon_provider": "url", "icons": {"plain": "https://e.example/c.svg"}, } @@ -99,10 +107,7 @@ def test_every_other_key_is_carried_through_untouched(): def test_does_not_mutate_the_config_it_was_given(): """For the json backend this dict is the db's live in-memory config, so resolving in place would rewrite what the deployment has stored.""" - styles = { - "icon_field": "type_of_place", - "icons": {"big bridge": {"provider": "phosphor", "value": "bridge"}}, - } + styles = {"icon_provider": "phosphor", "icons": {"big bridge": "bridge"}} before = copy.deepcopy(styles) icons_before = styles["icons"] From b606ebffa98ca4450fcce1ac09afb2f3acd69ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 22 Aug 2026 00:52:35 +0200 Subject: [PATCH 45/45] fif --- goodmap/goodmap.py | 17 ++++++++++------- goodmap/marker_styles.py | 23 +++++++++++++---------- tests/unit_tests/test_goodmap.py | 7 +++++-- tests/unit_tests/test_marker_styles.py | 21 ++++++++++----------- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 690ca25c..49b43224 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -175,11 +175,14 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: # (see docs/data-source.rst) - every backend's get_category_data()/ # get_marker_styles() already defaults them to {} internally. # - # marker_styles is resolved once, here, so window.MARKER_STYLES.icons is a flat - # {value: url} table and the frontend never has to know about icon providers. + # marker_styles splits two ways: the raw config says which *fields* drive a pin (app + # wiring, below), while resolve_marker_styles builds the icon/color lookup tables the + # browser gets - icons already flattened to plain URLs, so the frontend never has to + # know about icon providers. location_obligatory_fields = get_location_obligatory_fields(app.db) categories = get_category_data(app.db)(app.db)["categories"] - marker_styles = resolve_marker_styles(get_marker_styles(app.db)(app.db)) + raw_marker_styles = get_marker_styles(app.db)(app.db) + marker_styles = resolve_marker_styles(raw_marker_styles) location_model = create_location_model(location_obligatory_fields, categories) app.db = extend_db_with_goodmap_queries(app.db, location_model) @@ -187,13 +190,13 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: obligatory_field_names = {name for name, _ in location_obligatory_fields} pin_marker_fields = PinMarkerFields( icon_field=( - marker_styles.get("icon_field") - if marker_styles.get("icon_field") in obligatory_field_names + raw_marker_styles.get("icon_field") + if raw_marker_styles.get("icon_field") in obligatory_field_names else None ), color_field=( - marker_styles.get("color_field") - if marker_styles.get("color_field") in obligatory_field_names + raw_marker_styles.get("color_field") + if raw_marker_styles.get("color_field") in obligatory_field_names else None ), ) diff --git a/goodmap/marker_styles.py b/goodmap/marker_styles.py index 023196d4..95bf4eb8 100644 --- a/goodmap/marker_styles.py +++ b/goodmap/marker_styles.py @@ -7,7 +7,7 @@ separately versioned frontend bundle only ever has to understand URL strings. """ -from typing import Any, Protocol +from typing import Any, Mapping, Protocol class IconProvider(Protocol): @@ -56,8 +56,12 @@ def resolve(self, value: str) -> str: } -def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: - """Resolve marker_styles.icons into a flat {value: url} lookup table. +def resolve_marker_styles(marker_styles: Mapping[str, Any]) -> dict[str, Any]: + """The pin styling tables the frontend needs, with icons resolved to plain URLs. + + Only "icons" and "colors" cross to the browser - getTypedMarkerIcon.jsx reads exactly + those two. icon_field/color_field/icon_provider are how this deployment decides what a + pin looks like, which the page has no use for, so they are not built into the result. One "icon_provider" serves the whole table, so every entry is a plain value that provider understands - a Phosphor icon name, a URL - rather than each one restating @@ -67,12 +71,10 @@ def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: marker_styles: Raw marker_styles config as returned by goodmap.db.get_marker_styles(). Carries "icon_provider" (a name in ICON_PROVIDERS) whenever it carries "icons". May be empty or lack both. - Never mutated - for the json backend this is the db's live in-memory config, - so resolving in place would rewrite what is stored. Returns: - A new dict. "icons", if present, is replaced by a flat {value: url} map; every - other key (icon_field, color_field, colors) is carried through untouched. + {"icons": {value: url}, "colors": {value: css_color}}, built fresh rather than + copied from the config, so nothing here aliases what the deployment has stored. "colors" needs no resolving - it maps straight to CSS colors, with no provider. Raises: @@ -81,8 +83,9 @@ def resolve_marker_styles(marker_styles: dict[str, Any]) -> dict[str, Any]: same way a category with no allowed values does (see data_models.location.create_location_model). """ - resolved = dict(marker_styles) + resolved_icons: dict[str, str] = {} if icons := marker_styles.get("icons"): provider = ICON_PROVIDERS[marker_styles["icon_provider"]] - resolved["icons"] = {key: provider.resolve(value) for key, value in icons.items()} - return resolved + resolved_icons = {key: provider.resolve(value) for key, value in icons.items()} + + return {"icons": resolved_icons, "colors": marker_styles.get("colors", {})} diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 7807a30f..82accba2 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -150,15 +150,18 @@ def test_map_route_marker_styles(): response_text = response.data.decode("utf-8") assert "MARKER_STYLES" in response_text - assert "icon_field" in response_text assert "parcel_locker" in response_text + # icon_field/icon_provider decide what a pin looks like server-side; the page only + # needs the resulting tables, so they are not shipped to every visitor. + assert "icon_field" not in response_text + assert "icon_provider" not in response_text unconfigured_app = goodmap.create_app_from_config(_minimal_config()) unconfigured_app.config["WTF_CSRF_ENABLED"] = False # NOSONAR response = unconfigured_app.test_client().get("/map") assert response.status_code == 200 - assert "window.MARKER_STYLES={};" in response.data.decode("utf-8") + assert 'window.MARKER_STYLES={"colors":{},"icons":{}};' in response.data.decode("utf-8") def test_map_route_marker_styles_stay_in_step_with_the_api(): diff --git a/tests/unit_tests/test_marker_styles.py b/tests/unit_tests/test_marker_styles.py index a6b93dfa..9906b3d7 100644 --- a/tests/unit_tests/test_marker_styles.py +++ b/tests/unit_tests/test_marker_styles.py @@ -71,8 +71,8 @@ def test_malformed_config_stops_the_app_from_starting(styles): resolve_marker_styles(styles) -def test_empty_marker_styles_stays_empty(): - assert resolve_marker_styles({}) == {} +def test_empty_marker_styles_yields_empty_tables(): + assert resolve_marker_styles({}) == {"icons": {}, "colors": {}} @pytest.mark.parametrize("icons", [{}, None], ids=["empty-table", "no-icons-key"]) @@ -83,12 +83,12 @@ def test_nothing_to_resolve_needs_no_provider(icons): if icons is not None: styles["icons"] = icons - assert resolve_marker_styles(styles) == styles + assert resolve_marker_styles(styles) == {"icons": {}, "colors": {"10": "#2e7d32"}} -def test_every_other_key_is_carried_through_untouched(): - """colors maps straight to CSS colors and has no provider, so it - like the two field - names - must survive resolution unchanged.""" +def test_only_the_two_tables_the_frontend_reads_are_built(): + """icon_field/color_field/icon_provider decide what a pin looks like server-side; + getTypedMarkerIcon.jsx reads only icons and colors, so nothing else is shipped.""" styles = { "icon_field": "type_of_place", "color_field": "speed_limit", @@ -97,11 +97,10 @@ def test_every_other_key_is_carried_through_untouched(): "icons": {"plain": "https://e.example/c.svg"}, } - resolved = resolve_marker_styles(styles) - - assert resolved["icon_field"] == "type_of_place" - assert resolved["color_field"] == "speed_limit" - assert resolved["colors"] == {"10": "#2e7d32", "50": "#c62828"} + assert resolve_marker_styles(styles) == { + "icons": {"plain": "https://e.example/c.svg"}, + "colors": {"10": "#2e7d32", "50": "#c62828"}, + } def test_does_not_mutate_the_config_it_was_given():