Skip to content

Fix categorized styling for marker icons - #1782

Merged
giswqs merged 5 commits into
mainfrom
fix/discussion-1780-categorized-markers
Aug 8, 2026
Merged

Fix categorized styling for marker icons#1782
giswqs merged 5 commits into
mainfrom
fix/discussion-1780-categorized-markers

Conversation

@giswqs

@giswqs giswqs commented Aug 8, 2026

Copy link
Copy Markdown
Member

Summary

  • apply categorized, graduated, and rule-based color expressions to built-in marker sprites
  • resolve QGIS SVG color parameters for inline, data URL, and remote SVG markers
  • add regression coverage for built-in and custom categorized markers

Verification

  • reproduced with the PS1 point shapefile from discussion 1780
  • verified built-in circle markers and the supplied tree SVG in light and dark themes
  • npm run test:frontend
  • npm run build
  • pre-commit checks for changed files

Related to #1780

Summary by CodeRabbit

  • New Features

    • Marker icons can now change color based on feature data and style expressions.
    • Custom SVG markers support color overrides, including remotely hosted and data-based SVG sources.
    • External overlays and GeoJSON symbol layers now use configured marker image values.
  • Bug Fixes

    • Improved categorized marker rendering with distinct colors, custom SVG imagery, and fallback handling for unavailable sources.
    • Failed remote SVG requests are retried rather than incorrectly treated as permanently unavailable.
  • Tests

    • Added coverage for categorized markers, colored SVG sprites, recursive styling expressions, and remotely loaded SVGs.

Apply data-driven category colors to built-in and parameterized SVG marker sprites. Support inline, data URL, and remote SVG sources with focused regression coverage.
Copilot AI lite review requested due to automatic review settings August 8, 2026 17:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f4c74c9d-80a4-4121-8761-e7c3174ab219

📥 Commits

Reviewing files that changed from the base of the PR and between c276b0c and bd8c167.

📒 Files selected for processing (2)
  • packages/map/src/markers.ts
  • tests/marker-categories.test.ts

📝 Walkthrough

Walkthrough

Categorized marker colors now produce dynamic marker-image expressions. Custom SVG markers support color overrides and remote or data sources. External control and GeoJSON layers use resolved marker images, with KML icons retained as fallback.

Changes

Categorized marker rendering

Layer / File(s) Summary
Marker image generation
packages/map/src/markers.ts, tests/marker-categories.test.ts
markerImageValue converts match, step, and case color expressions into marker-image expressions. SVG sources support color substitution, bounded caching, and retry after fetch failure. Tests cover categorized markers, recursive expressions, invalid colors, custom SVG variants, and remote fetch retries.
Layer synchronization integration
packages/map/src/layer-sync.ts, packages/map/src/markers.ts
External control and GeoJSON symbol layers use dynamic marker-image property specifications. KML icon images remain supported as fallbacks, and point layers use circles when no marker image is available.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LayerSync
  participant markerImageValue
  participant prepareMarker
  participant MapLibre
  LayerSync->>markerImageValue: Resolve marker image property specification
  markerImageValue->>prepareMarker: Register color-specific marker variant
  prepareMarker->>MapLibre: Add marker image
  LayerSync->>MapLibre: Set dynamic icon-image specification
Loading

Possibly related PRs

  • opengeos/GeoLibre#1553: Both PRs modify shared markerImageValue and vectorColorExpression rendering logic.
  • opengeos/GeoLibre#1580: Both PRs modify dynamic marker-image expressions and marker handling in markers.ts and layer-sync.ts.
  • opengeos/GeoLibre#1609: Both PRs modify categorized style expression handling and feature-dependent marker color expressions.

Poem

A rabbit paints each marker bright,
With feature colors in flight.
SVGs fetch, then bloom anew,
KML icons guide them through.
MapLibre hops in colored light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change to categorized styling for marker icons.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/discussion-1780-categorized-markers

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

🔍 Cloudflare PR preview

Item Value
Site https://cdd1bd5a.geolibre-preview.pages.dev
Demo app https://cdd1bd5a.geolibre-preview.pages.dev/demo/
Commit bd8c167

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

🔍 GitHub Pages PR preview

Item Value
Site https://opengeos.org/pages-preview/GeoLibre/pr-1782/
Demo app https://opengeos.org/pages-preview/GeoLibre/pr-1782/demo/
Commit bd8c167

Note

GitHub Pages built this preview successfully, but its serving edge returned HTTP 403 when checked. The links may still be propagating.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/map/src/layer-sync.ts`:
- Around line 1924-1926: The KML icon expression currently falls back to the
fixed markerImageId, dropping categorized markers. Update the surrounding layer
expression and prepareKmlFeatureIcons usage so KML icons take priority while the
fallback uses markerImage, preserving categorized non-KML features; add coverage
for a layer containing both KML-icon and categorized features.

In `@packages/map/src/markers.ts`:
- Around line 261-264: Update the imageFor helper used by markerImageValue so
non-string or invalid-hex branch values return baseId instead of the original
value; preserve prepareMarker for valid hex colors. Add a regression test
covering an invalid branch output such as "red" and verify it resolves to the
base marker.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d10db00c-1d94-41b1-867b-1d74b5942460

📥 Commits

Reviewing files that changed from the base of the PR and between c9b7e1a and 8beb1cc.

📒 Files selected for processing (3)
  • packages/map/src/layer-sync.ts
  • packages/map/src/markers.ts
  • tests/marker-categories.test.ts

Comment thread packages/map/src/layer-sync.ts Outdated
Comment thread packages/map/src/markers.ts Outdated
Comment thread packages/map/src/markers.ts
Comment thread packages/map/src/markers.ts
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • markerImageValue (packages/map/src/markers.ts:253-287) only converts color leaves one level deep. Rule-based styling with a per-rule scale range (VectorRule.minZoom/maxZoom) produces a top-level step whose outputs are nested case expressions, not flat color strings; the "step" branch's imageFor call only handles typeof value === "string", so those nested case colors never get remapped to marker sprite ids. Markers on a rule-based, scale-ranged layer would render with unresolved color strings as icon-image. Confidence: medium-high.

Performance

  • colorizedSvgSource (packages/map/src/markers.ts:101-114) re-fetches the same remote/data-URL SVG once per distinct category color (via prepareMarker(style, value) per match/step/case branch), multiplying outbound requests by class count for custom-SVG categorized layers instead of fetching the markup once and reusing it. Confidence: medium.

Quality

  • The new tests/marker-categories.test.ts only exercises the "match" (categorized) branch of markerImageValue; the "step" (graduated) and "case" (rule-based) branches — including the zoom-wrapped nested-case scenario above — have no coverage, so the bug wouldn't surface in CI. Confidence: high (directly observable from the diff).

Security

  • No issues found. The fetch(markup) addition in colorizedSvgSource is a client-side, same-origin/CORS-governed request equivalent to the pre-existing <img crossorigin> load path, and all color substitutions come from values already validated by isHexColor/normalizeHexColor, so there's no injection vector from the new param(fill)/param(outline) text replacement.

CLAUDE.md

  • No violations found; the change stays within packages/map and adds a tests/*.test.ts regression file per the documented frontend test convention.

giswqs added 2 commits August 8, 2026 13:30
- preserve categorized fallbacks for mixed KML icon layers
- fall back safely for invalid color outputs
- recursively translate zoom-scoped rule color expressions
- cache remote SVG source text across per-color sprite generation
- bound the source cache to avoid unbounded session growth

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/map/src/markers.ts`:
- Around line 22-24: Update the SVG source caching flow around svgSourceCache so
resolved null results are not retained indefinitely: preserve in-flight promise
deduplication, then remove failed entries after a null result or apply a short
negative-cache TTL while retaining successful sources. Add a regression test
covering a failed fetch followed by a successful retry for the same source.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ec968697-1f78-4c8b-aaf2-101b97e972f4

📥 Commits

Reviewing files that changed from the base of the PR and between 0b57f9f and 2c2b6c0.

📒 Files selected for processing (1)
  • packages/map/src/markers.ts

Comment thread packages/map/src/markers.ts
Comment thread packages/map/src/markers.ts Outdated
Comment thread packages/map/src/markers.ts Outdated
Comment thread packages/map/src/markers.ts
Comment thread packages/map/src/layer-sync.ts Outdated
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • markerImageValue (packages/map/src/markers.ts:266-272) discards a resolved flat color that differs from fallback — reachable in rule-based mode when there are zero active/valid rules but an enabled else-rule with its own color. The marker sprite falls back to the layer's default markerColor instead of the else-rule's color, while the equivalent circle/fill paint renders correctly. Confidence: medium.

Performance

  • colorizedSvgSource (packages/map/src/markers.ts:103-127) permanently caches a failed remote-SVG fetch as null, so a transient network failure disables QGIS param(fill) color resolution for that URL for the rest of the session (until 64 other SVGs evict the entry or the app reloads). Confidence: low-medium.

Quality

  • layer-sync.ts:1923-1926 computes markerImageId via a second, redundant prepareMarker call that always has the same null-ness as markerImage; the ?? markerImageId fallback on line 1926 is unreachable dead code and the !markerImageId check at line 2244 could just use !markerImage. Also, imageFor's new Set(["match","step","case"]) (markers.ts:282) is reallocated on every call/recursion — could be a module-level constant. Confidence: low.

Security / CLAUDE.md

  • Nothing notable. The remote-SVG fetch path is a plain same-origin-policy-respecting fetch (CORS failures are caught and gracefully degrade to the un-recolored source), and no new external hosts, secrets, or CLAUDE.md-covered mirrored constants are introduced by this change.

I traced the match/step/case recursion in markerImageValue against the new test suite (tests/marker-categories.test.ts) and confirmed the index arithmetic is correct for well-formed expressions of each type, including nested step-wrapping-case (zoom-scoped rules) and categorized/graduated/expression modes — no issues there beyond the rule-based edge case above.

- markerImageValue: route a flat resolved color through imageFor instead of
  returning the base sprite. Rule-based mode with no drawable rules returns the
  else rule's color, which need not equal the layer's markerColor, so the marker
  was baked in the wrong color while the circle paint used the else color.
- colorizedSvgSource: drop a failed remote-SVG fetch from svgSourceCache after
  the await, so a transient error no longer disables QGIS color-parameter
  resolution for that URL for the rest of the session; in-flight dedup is kept.
- layer-sync: remove the redundant prepareMarker call. markerImageValue resolves
  the same base marker internally, so markerImageId was non-null exactly when
  markerImage was — the `?? markerImageId ?? ""` fallback was dead code.
- markers: hoist the match/step/case head set to a module constant instead of
  allocating it on every (recursive) imageFor call.
- tests: cover the else-rule-only color bake and a failed-then-successful remote
  SVG fetch.
Comment thread packages/map/src/markers.ts Outdated
Comment thread tests/marker-categories.test.ts
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Both inline comments posted. Now writing the final summary.

Code review

Bugs

  • packages/map/src/markers.ts:281imageFor's string branch validates the color with normalizeHexColor(value) but then passes the raw, un-normalized value to prepareMarker. Since normalizeHexColor accepts hex without a leading # (and 3-digit shorthand) and expands it, a user-authored expression-mode match/case/step branch that outputs a bare hex string (e.g. "fff") passes validation but is then used verbatim as ctx.fillStyle/SVG fill, which is not valid CSS — the marker silently renders in the canvas's default color instead of the intended one. Medium confidence (real logic bug, but requires an unusual expression-mode input to trigger).

Security

  • None found. Color values substituted into SVG markup (replaceSvgColorParameters) are always pre-validated as strict #rrggbb hex via isHexColor/normalizeHexColor before reaching that path in the normal (non-expression) modes, so no injection risk. Remote SVG fetches go through the browser's normal CORS handling and failures degrade gracefully without caching negative results.

Performance

  • None found. The new svgSourceCache correctly dedups concurrent fetches of the same remote/data-URL SVG source across differently-colored variants, is capped at 64 entries, and evicts failed fetches immediately so a transient network error doesn't block future retries.

Quality

  • tests/marker-categories.test.ts:29 — no direct test exercises vectorStyleMode: "graduated" (the numeric step expression), even though the PR explicitly claims graduated marker support; coverage relies on an indirect, hand-authored zoom-step expression instead. Low confidence/severity, test-coverage gap only.
  • Same root cause as the bug above: because prepareMarker's colorOverride isn't normalized, two case-different but equivalent hex strings (e.g. "#FDE725" vs "#fde725") would produce distinct sprite ids/cache entries for the same visual color — a minor cache-efficiency nit, not a correctness issue on its own.

CLAUDE.md

  • No violations found. The change stays within packages/map/@geolibre/core, adds regression tests under tests/, and doesn't touch any of the mirrored-constant or generated-catalog files the guidelines call out.

- markerImageValue: pass the normalized hex color to prepareMarker instead of
  the raw expression output. normalizeHexColor accepts bare and shorthand hex,
  but prepareMarker uses the color verbatim for the sprite id and the canvas
  fill, so an expression emitting "fff" baked a sprite that drew black, and
  "#FDE725" baked a duplicate of an already-registered lowercase color.
- tests: add a graduated-mode case (the numeric step from graduatedStops) and a
  shorthand/upper-case hex case.
Comment on lines +279 to +300
const imageFor = (value: unknown): unknown => {
if (typeof value === "string") {
// Bake the canonical form: prepareMarker uses the color verbatim for both
// the sprite id and the fill, so a bare or shorthand hex ("fff") from a
// hand-authored expression would otherwise draw black, and "#FDE725"
// would bake a second sprite for a color already registered lowercase.
const normalized = normalizeHexColor(value);
return normalized ? (prepareMarker(style, normalized) ?? baseId) : baseId;
}
if (!Array.isArray(value)) return baseId;

const expression = [...value];
const firstOutput = expression[0] === "match" ? 3 : 2;
if (!COLOR_BRANCH_HEADS.has(String(expression[0]))) return baseId;
for (let index = firstOutput; index < expression.length; index += 2) {
expression[index] = imageFor(expression[index]);
}
if (expression[0] !== "step") {
expression[expression.length - 1] = imageFor(expression[expression.length - 1]);
}
return expression;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality / completeness (medium confidence): imageFor only recolors a branch when its output is a literal hex string (or a nested match/step/case). In "expression" mode (free-form JSON typed by the user), a color output that is a CSS named color ("red", "steelblue"), an rgb()/rgba()/hsl() string, or a nested color-producing sub-expression (["to-color", …], ["rgb", …]) fails normalizeHexColor/COLOR_BRANCH_HEADS and silently collapses to the single fallback marker color for that whole branch — with no warning surfaced to the user.

This is intentional per the "uses the base marker for invalid expression color outputs" test, but the mismatch could be confusing: the same expression correctly colors fill/line/circle layers per-feature (via vectorFillColorValue/vectorLineColorValue, which pass the raw expression straight to MapLibre) while the marker icon quietly reverts to one flat color. Worth either a one-line note in the markerImageValue JSDoc calling this out as a known limitation, or (as a follow-up) resolving arbitrary CSS colors to hex via a throwaway canvas ctx.fillStyle round-trip before falling back.

Comment on lines +105 to +135
async function colorizedSvgSource(markup: string, color: string): Promise<string | null> {
let sourceMarkup = markup;
if (/^(?:https?:|data:image\/svg\+xml)/i.test(markup)) {
let pending = svgSourceCache.get(markup);
if (!pending) {
pending = fetch(markup)
.then((response) => (response.ok ? response.text() : null))
.catch(() => null);
if (svgSourceCache.size >= MAX_SVG_SOURCE_CACHE) {
const oldest = svgSourceCache.keys().next().value;
if (oldest !== undefined) svgSourceCache.delete(oldest);
}
svgSourceCache.set(markup, pending);
}
const fetched = await pending;
if (fetched !== null) {
sourceMarkup = fetched;
} else {
// Do not keep a failed fetch cached: a transient network error would
// otherwise block every later color variant of the same source (and any
// styleimagemissing retry) until the entry is evicted. Dropping it only
// after the await still lets concurrent callers share the in-flight
// promise.
if (svgSourceCache.get(markup) === pending) svgSourceCache.delete(markup);
// Preserve the original source when a remote host blocks CORS. The
// marker still renders, although its QGIS color parameters cannot be
// resolved without access to the SVG text.
}
}
return resolveSvgSource(replaceSvgColorParameters(sourceMarkup, color));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security note (low confidence): this is the first place in markers.ts that reads a remote/data-URL SVG marker's text into JS (previously loadSvgMarker/loadMarkerSvgImage only ever set it as an <img src>, so the response body itself was never exposed to script). markerSvg is free-text project data — a shared/imported .geolibre.json (or a "expression"-mode style) can already point it at an arbitrary URL, and this fetch() runs automatically whenever the marker sprite is generated, with no user interaction beyond opening the project.

The existing console.warn in resolveSvgSource (packages/core/src/marker-shape.ts) already accepts that a remote markerSvg triggers a cross-origin request, so this may be an accepted extension of that trust boundary rather than a new one — flagging mainly because reading the response text (vs. only rendering it as an image) is a meaningfully larger capability than what existed before, worth a second look given untrusted projects are a supported flow (collaboration / imported style files).

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • No correctness bugs found. I traced the imageFor recursion in markerImageValue against match/step/case index arithmetic (including nested zoom-stepped rule expressions and multi-label match cases) and it's sound; the new markerImage/kmlIconImage fallback wiring in layer-sync.ts preserves the prior null-ness contract and styleValuesEqual's deep-compare prevents the new array-valued icon-image from causing spurious setLayoutProperty churn each sync. High confidence.

Security

  • Low confidence: colorizedSvgSource (packages/map/src/markers.ts:105) is the first place that reads a remote/data-URL SVG's response text into JS rather than only rendering it via <img>. Given markerSvg can come from untrusted shared projects and the fetch fires automatically on sprite generation, this modestly extends an already-accepted cross-origin trust boundary — worth a second look, not necessarily a blocker. See inline comment.

Performance

  • No significant issues. The 512-entry generated-image factory cap is shared globally and categorized/rule-based marker layers can now register far more sprites per layer (up to 256 categories via the UI), but registerGeneratedImage's self-healing re-registration-on-next-sync (it detects and repairs a blanked live image) makes eviction self-correcting rather than a persistent bug. Not flagged as a finding.
  • The remote-SVG fetch path adds a cache with sane FIFO eviction and correctly avoids caching failed fetches so retries work after a style swap.

Quality

  • Medium confidence: in free-form "expression" mode, imageFor only recolors branches whose output is a literal hex string; CSS named colors, rgb()/hsl() strings, or nested color expressions (["to-color", …]) silently fall back to the single flat marker color with no warning, while the same expression correctly drives per-feature fill/line/circle color. This is deliberate (covered by a test) but underdocumented — see inline comment for a suggested JSDoc note or a canvas-based color-normalization follow-up.

CLAUDE.md

  • No violations found. The new fetch() calls target hosts already covered by the desktop CSP's permissive connect-src https: rule, so no CSP allowlist update was needed.

Two inline comments posted on packages/map/src/markers.ts covering the two findings above.

@giswqs
giswqs merged commit f8b0e89 into main Aug 8, 2026
24 checks passed
@giswqs
giswqs deleted the fix/discussion-1780-categorized-markers branch August 8, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants