Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
95bf915
feat: added markers styling
raven-wing Aug 17, 2026
c50fa7d
fixes
raven-wing Aug 19, 2026
242cb5f
fix popup
raven-wing Aug 19, 2026
c76d8ef
some icon fixes
raven-wing Aug 20, 2026
b99e8e5
fix lint
raven-wing Aug 20, 2026
e15aa03
fix linting
raven-wing Aug 20, 2026
30088a2
some fixes
raven-wing Aug 20, 2026
6b2bc2c
fixes
raven-wing Aug 20, 2026
049554d
some trims
raven-wing Aug 20, 2026
d063439
some fixes
raven-wing Aug 20, 2026
3e19819
added missing marker
raven-wing Aug 20, 2026
e289645
cleanup
raven-wing Aug 20, 2026
4ff6c8a
cleanup
raven-wing Aug 20, 2026
0c3ee6a
cleanup comment
raven-wing Aug 20, 2026
3597a66
more human readable icons
raven-wing Aug 20, 2026
3457d33
some cleanup
raven-wing Aug 20, 2026
d2096e4
cleanup
raven-wing Aug 20, 2026
121cd64
cleanup comments
raven-wing Aug 20, 2026
56d1b13
cleanup
raven-wing Aug 20, 2026
d62f19d
fixes
raven-wing Aug 20, 2026
e2a608a
fixes
raven-wing Aug 20, 2026
34a1f84
fixes
raven-wing Aug 20, 2026
41cfcac
fix
raven-wing Aug 20, 2026
309a79d
added missing files
raven-wing Aug 20, 2026
0063c97
fix
raven-wing Aug 20, 2026
fe0fb27
refactor
raven-wing Aug 20, 2026
50fad55
refactor
raven-wing Aug 20, 2026
3c0b60d
lot of files changes
raven-wing Aug 20, 2026
b676b67
fixes
raven-wing Aug 21, 2026
ff4dcd8
a lot of refactor
raven-wing Aug 21, 2026
bbbbd22
removed compress
raven-wing Aug 21, 2026
bda07f7
fix sonar errors
raven-wing Aug 21, 2026
746c996
fixes after review
raven-wing Aug 21, 2026
d316152
lint fixes
raven-wing Aug 21, 2026
77bf36e
fix for ci problems
raven-wing Aug 21, 2026
d45271c
fixes
raven-wing Aug 21, 2026
f51abbe
update
raven-wing Aug 21, 2026
c4c8821
missing file
raven-wing Aug 21, 2026
c31459e
less comments
raven-wing Aug 21, 2026
08a870b
less comments
raven-wing Aug 21, 2026
954c924
less comments
raven-wing Aug 21, 2026
fe9925f
little simplification
raven-wing Aug 21, 2026
db52516
simplify
raven-wing Aug 21, 2026
092ace9
less code
raven-wing Aug 21, 2026
b606ebf
fif
raven-wing Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions e2e-tests/e2e_test_data_initial.json
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,20 @@
"cars"
]
},
"marker_styles": {
"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"
},
"colors": {
"10": "#2e7d32",
"30": "#ef6c00",
"50": "#c62828"
},
"default_color": "#2a81cb"
},
"visible_data": [
"remark",
"accessible_by",
Expand Down
90 changes: 90 additions & 0 deletions e2e-tests/tests/basic/test_marker_styles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""
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" 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:
"""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)

# The pin shape itself (a masked div, not an inline <path>), 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 <path>.
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("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_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)

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("span")).to_have_text("*")
14 changes: 11 additions & 3 deletions frontend/src/components/MarkerPopup/MarkerPopup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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';

/**
Expand Down Expand Up @@ -37,7 +38,7 @@
}
} catch (error) {
if (isMounted) {
console.error('Failed to fetch location:', error);

Check warning on line 41 in frontend/src/components/MarkerPopup/MarkerPopup.jsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
setPlace({ error: true });
}
}
Expand Down Expand Up @@ -122,9 +123,16 @@
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;
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/MarkerPopup/ReportProblemForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ const ReportProblemForm = ({ placeId }) => {
if (schemaError) {
return (
<ErrorMessage>
{t('loadReportFormError')}
<span role="alert">{t('loadReportFormError')}</span>
<div>
<RetryButton type="button" onClick={refetchLocationSchema}>
{t('retry')}
Expand Down
162 changes: 162 additions & 0 deletions frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React from 'react';
import PropTypes from 'prop-types';
import { DivIcon } from 'leaflet';
import ReactDOMServer from 'react-dom/server';

const PIN_WIDTH = 72;
const PIN_HEIGHT = 80;
const FALLBACK_COLOR = '#2a81cb'; // leaflet default marker blue

// 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',
});

/**
* 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 PinIcon = ({ color, glyphUrl, hasRemark }) => (
<div style={{ position: 'relative', width: PIN_WIDTH, height: PIN_HEIGHT }}>
<div
className="custom-typed-marker-pin"
style={{
position: 'absolute',
inset: 0,
filter: 'drop-shadow(0 0 1px #fff) drop-shadow(0 0 1px #fff)',
...maskStyle(PIN_SHAPE_URL, color),
}}
/>
{glyphUrl !== '' && (
<div
className="custom-typed-marker-glyph"
style={{
position: 'absolute',
top: GLYPH_OFFSET_TOP,
left: GLYPH_OFFSET_LEFT,
width: GLYPH_SIZE,
height: GLYPH_SIZE,
...maskStyle(glyphUrl, '#ffffff'),
}}
/>
)}
{hasRemark && (
<span
style={{
position: 'absolute',
top: -3,
left: 38,
fontSize: 36,
fontWeight: 'bold',
lineHeight: 1,
color: '#ffffff',
textShadow: [-1, 1]
.flatMap(x => [-1, 1].map(y => `${x}px ${y}px 0 ${color}`))
.join(', '),
}}
>
*
</span>
)}
</div>
);

PinIcon.propTypes = {
color: PropTypes.string.isRequired,
glyphUrl: 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: '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
* }
*
* @param {Object} place - Location data, as returned by GET /api/locations
* @returns {import('leaflet').DivIcon|null}
*/
const getTypedMarkerIcon = place => {
const markerStyles = globalThis.MARKER_STYLES || {};
const {
icon_field: iconField,
color_field: colorField,
icons,
colors,
default_color: defaultColor,
} = markerStyles;

const glyphUrl = (iconField && icons && icons[place[iconField]]) || '';

Check warning on line 140 in frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=Problematy_goodmap&issues=AaAcW0kxTFCFV9GvuwYR&open=AaAcW0kxTFCFV9GvuwYR&pullRequest=393
const matchedColor = (colorField && colors && colors[place[colorField]]) || '';

Check warning on line 141 in frontend/src/components/MarkerPopup/getTypedMarkerIcon.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=Problematy_goodmap&issues=AaARbAM8EM5UgkcrUVvM&open=AaARbAM8EM5UgkcrUVvM&pullRequest=393

if (!glyphUrl && !matchedColor) {
return null;
}

return new DivIcon({
html: ReactDOMServer.renderToString(
<PinIcon
color={matchedColor || defaultColor || FALLBACK_COLOR}
glyphUrl={glyphUrl}
hasRemark={Boolean(place.has_remark)}
/>,
),
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],
});
};

export default getTypedMarkerIcon;
Loading
Loading