Skip to content

Commit c276b0c

Browse files
committed
Address review feedback
- 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.
1 parent 2c2b6c0 commit c276b0c

3 files changed

Lines changed: 98 additions & 9 deletions

File tree

packages/map/src/layer-sync.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ import {
6363
markerImageValue,
6464
markerIconSizeValue,
6565
prepareKmlFeatureIcons,
66-
prepareMarker,
6766
} from "./markers";
6867
import { isPlaceholderLayer } from "./placeholders";
6968
import {
@@ -1921,9 +1920,10 @@ function applyVectorDataRenderLayers(
19211920
// a generated image id.
19221921
ensureGeneratedImageHandler(map);
19231922
const fillPatternId = prepareFillPattern(layer.style);
1924-
const markerImageId = prepareMarker(layer.style);
1923+
// markerImageValue resolves the same base marker internally, so it is null
1924+
// exactly when no marker applies — no separate prepareMarker call is needed.
19251925
const markerImage = markerImageValue(layer.style);
1926-
const kmlIconImage = prepareKmlFeatureIcons(layer.geojson!, markerImage ?? markerImageId ?? "");
1926+
const kmlIconImage = prepareKmlFeatureIcons(layer.geojson!, markerImage ?? "");
19271927
// Derived companion symbology (inverted mask, geometry generator, dedup
19281928
// labels) is built from the raw features, so no MapLibre filter applies to
19291929
// it. While a Time Slider window or a rule-based visibility filter is
@@ -2241,7 +2241,7 @@ function applyVectorDataRenderLayers(
22412241
},
22422242
beforeId,
22432243
);
2244-
if (kmlIconImage && !markerImageId) {
2244+
if (kmlIconImage && !markerImage) {
22452245
// Features without a KML icon still use the ordinary circle renderer.
22462246
ensureLayer(
22472247
map,

packages/map/src/markers.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const MAX_MARKER_SIZE = 96;
2222
const MAX_SVG_SOURCE_CACHE = 64;
2323
export const KML_ICON_URL_PROPERTY = "__geolibre_kml_icon_url";
2424
const svgSourceCache = new Map<string, Promise<string | null>>();
25+
// The expression heads whose outputs markerImageValue rewrites into sprite ids.
26+
const COLOR_BRANCH_HEADS: ReadonlySet<string> = new Set(["match", "step", "case"]);
2527

2628
const BUILTIN_SHAPES: ReadonlySet<MarkerShape> = new Set([
2729
"circle",
@@ -118,6 +120,12 @@ async function colorizedSvgSource(markup: string, color: string): Promise<string
118120
if (fetched !== null) {
119121
sourceMarkup = fetched;
120122
} else {
123+
// Do not keep a failed fetch cached: a transient network error would
124+
// otherwise block every later color variant of the same source (and any
125+
// styleimagemissing retry) until the entry is evicted. Dropping it only
126+
// after the await still lets concurrent callers share the in-flight
127+
// promise.
128+
if (svgSourceCache.get(markup) === pending) svgSourceCache.delete(markup);
121129
// Preserve the original source when a remote host blocks CORS. The
122130
// marker still renders, although its QGIS color parameters cannot be
123131
// resolved without access to the SVG text.
@@ -268,9 +276,6 @@ export function markerImageValue(style: LayerStyle): string | unknown[] | null {
268276
const baseId = prepareMarker(style, fallback);
269277
if (!baseId) return null;
270278

271-
const colorValue = vectorColorExpression(style, fallback);
272-
if (!Array.isArray(colorValue)) return baseId;
273-
274279
const imageFor = (value: unknown): unknown => {
275280
if (typeof value === "string") {
276281
return normalizeHexColor(value) ? (prepareMarker(style, value) ?? baseId) : baseId;
@@ -279,7 +284,7 @@ export function markerImageValue(style: LayerStyle): string | unknown[] | null {
279284

280285
const expression = [...value];
281286
const firstOutput = expression[0] === "match" ? 3 : 2;
282-
if (!new Set(["match", "step", "case"]).has(String(expression[0]))) return baseId;
287+
if (!COLOR_BRANCH_HEADS.has(String(expression[0]))) return baseId;
283288
for (let index = firstOutput; index < expression.length; index += 2) {
284289
expression[index] = imageFor(expression[index]);
285290
}
@@ -288,7 +293,10 @@ export function markerImageValue(style: LayerStyle): string | unknown[] | null {
288293
}
289294
return expression;
290295
};
291-
return imageFor(colorValue) as unknown[];
296+
// A flat resolved color still goes through imageFor: rule-based mode with no
297+
// drawable rules returns the else rule's color, which need not equal the
298+
// layer's markerColor that baseId was baked from.
299+
return imageFor(vectorColorExpression(style, fallback)) as string | unknown[];
292300
}
293301

294302
function loadRasterMarker(url: string): Promise<GeneratedImageResult | null> {

tests/marker-categories.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
33
import { DEFAULT_LAYER_STYLE, type LayerStyle } from "@geolibre/core";
4+
import { ensureGeneratedImageHandler } from "../packages/map/src/generated-images";
45
import {
56
KML_ICON_URL_PROPERTY,
67
markerImageValue,
78
prepareKmlFeatureIcons,
9+
prepareMarker,
810
} from "../packages/map/src/markers";
911

1012
function categorizedMarker(patch: Partial<LayerStyle> = {}): LayerStyle {
@@ -107,6 +109,26 @@ describe("markerImageValue", () => {
107109
]);
108110
});
109111

112+
it("bakes the else-rule color when no rule is drawable", () => {
113+
const value = markerImageValue(
114+
categorizedMarker({
115+
vectorStyleMode: "rule-based",
116+
vectorRules: [
117+
{
118+
id: "else",
119+
label: "Other",
120+
filter: "",
121+
color: "#fde725",
122+
enabled: true,
123+
isElse: true,
124+
},
125+
],
126+
}),
127+
);
128+
129+
assert.equal(value, "geolibre-marker-circle-fde725-18");
130+
});
131+
110132
it("keeps categorized marker fallback inside a mixed KML icon expression", () => {
111133
const markerImage = markerImageValue(categorizedMarker());
112134
const value = prepareKmlFeatureIcons(
@@ -132,3 +154,62 @@ describe("markerImageValue", () => {
132154
assert.deepEqual(value[value.length - 1], markerImage);
133155
});
134156
});
157+
158+
describe("custom SVG marker fetches", () => {
159+
it("retries a remote SVG whose first fetch failed", async () => {
160+
// Run the registered sprite factory the way styleimagemissing does.
161+
let missing: ((event: { id: string }) => void) | undefined;
162+
const map = {
163+
on: (_event: string, handler: (event: { id: string }) => void) => {
164+
missing = handler;
165+
},
166+
hasImage: () => false,
167+
addImage: () => {},
168+
};
169+
ensureGeneratedImageHandler(map as never);
170+
171+
// The sprite is rasterized through an Image; erroring out keeps the test
172+
// off the canvas APIs while still exercising the fetch path.
173+
class StubImage {
174+
decoding = "";
175+
crossOrigin = "";
176+
onload: (() => void) | null = null;
177+
onerror: (() => void) | null = null;
178+
set src(_value: string) {
179+
queueMicrotask(() => this.onerror?.());
180+
}
181+
}
182+
const previousImage = globalThis.Image;
183+
const previousFetch = globalThis.fetch;
184+
let calls = 0;
185+
globalThis.Image = StubImage as never;
186+
globalThis.fetch = (() => {
187+
calls += 1;
188+
return calls === 1
189+
? Promise.reject(new Error("offline"))
190+
: Promise.resolve({ ok: true, text: () => Promise.resolve("<svg/>") });
191+
}) as never;
192+
193+
try {
194+
const id = prepareMarker(
195+
categorizedMarker({
196+
markerShape: "custom",
197+
markerSvg: "https://example.com/retry.svg",
198+
vectorStyleMode: "single",
199+
}),
200+
);
201+
assert.ok(id);
202+
missing?.({ id });
203+
await new Promise((resolve) => setTimeout(resolve, 0));
204+
missing?.({ id });
205+
await new Promise((resolve) => setTimeout(resolve, 0));
206+
} finally {
207+
globalThis.Image = previousImage;
208+
globalThis.fetch = previousFetch;
209+
}
210+
211+
// A failed fetch must not be cached, or the marker stays uncolorized for
212+
// the rest of the session.
213+
assert.equal(calls, 2);
214+
});
215+
});

0 commit comments

Comments
 (0)