From 3c8bc9287e908d31a57a86b6d3f1ff027152c365 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Thu, 6 Aug 2026 08:28:48 +0300 Subject: [PATCH] fix(carto): render point label backgrounds --- modules/carto/src/layers/point-label-layer.ts | 60 ++++++++++--- .../carto/layers/point-label-layer.spec.ts | 81 +++++++++++++++++- .../point-label-layer-background.png | Bin 0 -> 9873 bytes test/render/test-cases/text-layer.spec.ts | 33 +++++++ 4 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 test/render/golden-images/point-label-layer-background.png diff --git a/modules/carto/src/layers/point-label-layer.ts b/modules/carto/src/layers/point-label-layer.ts index 56162716f7b..069763f1c84 100644 --- a/modules/carto/src/layers/point-label-layer.ts +++ b/modules/carto/src/layers/point-label-layer.ts @@ -40,18 +40,43 @@ class EnhancedTextBackgroundLayer extends TextBackgroundLayer { } } -// TextLayer which includes modified text-background-layer-vertex shader and only renders the -// primary background layer in the collision pass +type EnhancedTextLayerProps = TextLayerProps & { + renderBackground?: boolean; + collisionBackgroundPadding?: TextLayerProps['backgroundPadding']; +}; + +// TextLayer which includes modified text-background-layer-vertex shader and uses a separate +// background layer for collision testing, while preserving TextLayer's visual background support. class EnhancedTextLayer extends TextLayer { static layerName = 'EnhancedTextLayer'; filterSubLayer({layer, renderPass}) { - const background = layer.id.includes('primary-background'); + const collisionBackground = layer.id.endsWith('-collision-background'); if (renderPass === 'collision') { - return background; // Only draw primary background layer in collision pass + return collisionBackground; // Only draw collision background layer in collision pass } - return !background; // Do not draw background layer in other passes + return !collisionBackground; // Do not draw collision background layer in other passes + } + + renderLayers(): ReturnType { + const layers = super.renderLayers(); + const backgroundLayer = layers[0] as TextBackgroundLayer | false; + const charactersLayer = layers[1]; + const {renderBackground, collisionBackgroundPadding} = this.props as EnhancedTextLayerProps; + + const collisionBackgroundLayer = + backgroundLayer && + new EnhancedTextBackgroundLayer(backgroundLayer.props, { + id: `${this.id}-collision-background`, + padding: collisionBackgroundPadding + }); + + return [ + Boolean(renderBackground) && backgroundLayer, + collisionBackgroundLayer, + charactersLayer + ]; } } @@ -154,13 +179,13 @@ export default class PointLabelLayer< ]; } - calculateBackgroundPadding() { + calculateBackgroundPadding(): [number, number, number, number] { const {getTextAnchor: anchor, getAlignmentBaseline: alignment, sizeScale} = this.props; // Heuristics to avoid label overlap const paddingX = 12 * sizeScale; const paddingY = 3 * sizeScale; - const backgroundPadding = [0, 0, 0, 0]; + const backgroundPadding: [number, number, number, number] = [0, 0, 0, 0]; if (alignment === 'top') { backgroundPadding[TOP] = paddingY; } else if (alignment === 'bottom') { @@ -192,6 +217,11 @@ export default class PointLabelLayer< outlineColor, outlineWidth, sizeScale, + getBackgroundColor, + getBorderColor, + getBorderWidth, + backgroundBorderRadius, + backgroundPadding, radiusScale, getAlignmentBaseline, @@ -223,6 +253,11 @@ export default class PointLabelLayer< outlineColor, outlineWidth, sizeScale, + getBackgroundColor, + getBorderColor, + getBorderWidth, + backgroundBorderRadius, + backgroundPadding, getAlignmentBaseline, getColor, @@ -242,8 +277,7 @@ export default class PointLabelLayer< } }), { - getSize: 1, - _subLayerProps: {background: {type: EnhancedTextBackgroundLayer}} + getSize: 1 }, props ); @@ -251,6 +285,7 @@ export default class PointLabelLayer< renderLayers(): Layer | null | LayersList { const { + background, getText, getSecondaryColor, getSecondaryText, @@ -259,14 +294,15 @@ export default class PointLabelLayer< updateTriggers } = this.props; const getPixelOffset = this.calculatePixelOffset(false); - const backgroundPadding = this.calculateBackgroundPadding(); + const collisionBackgroundPadding = this.calculateBackgroundPadding(); const out = [ // Text doesn't update via updateTrigger for some reason this.renderTextLayer(`${updateTriggers.getText}-primary`, { - backgroundPadding, + background: true, + renderBackground: background, getText, getPixelOffset, - background: true // Only use background for primary label for faster collisions + collisionBackgroundPadding }), Boolean(getSecondaryText) && this.renderTextLayer(`${updateTriggers.getSecondaryText}-secondary`, { diff --git a/test/modules/carto/layers/point-label-layer.spec.ts b/test/modules/carto/layers/point-label-layer.spec.ts index ba627627ec5..85419313157 100644 --- a/test/modules/carto/layers/point-label-layer.spec.ts +++ b/test/modules/carto/layers/point-label-layer.spec.ts @@ -37,7 +37,7 @@ test('PointLabelLayer', () => { expect( !textLayer.filterSubLayer({layer: textBackgroundLayer, renderPass: 'draw'}), - 'background not drawn in draw pass' + 'collision background not drawn in draw pass' ).toBeTruthy(); expect( textLayer.filterSubLayer({layer: multiIconLayer, renderPass: 'draw'}), @@ -53,6 +53,85 @@ test('PointLabelLayer', () => { ).toBeTruthy(); } }, + { + props: { + data: FIXTURES.geojson, + background: true, + getBackgroundColor: [255, 255, 255, 200], + getBorderColor: [255, 0, 0, 255], + getBorderWidth: 2, + backgroundBorderRadius: 4, + backgroundPadding: [5, 6, 7, 8] + }, + onAfterUpdate: ({subLayers}) => { + const [textLayer] = subLayers; + const textSubLayers = textLayer.getSubLayers(); + expect( + textSubLayers.length, + 'visual background, collision background and text created' + ).toBe(3); + + const visualBackgroundLayer = textSubLayers.find( + layer => layer.id.endsWith('-background') && !layer.id.endsWith('-collision-background') + )!; + const collisionBackgroundLayer = textSubLayers.find(layer => + layer.id.endsWith('-collision-background') + )!; + const multiIconLayer = textSubLayers.find( + layer => layer.constructor.layerName === 'MultiIconLayer' + )!; + + expect(visualBackgroundLayer, 'visual background subLayer created').toBeTruthy(); + expect(collisionBackgroundLayer, 'collision background subLayer created').toBeTruthy(); + expect(multiIconLayer, 'text subLayer created').toBeTruthy(); + expect( + visualBackgroundLayer.constructor.layerName, + 'visual background uses the standard TextLayer shader' + ).toBe('TextBackgroundLayer'); + expect( + collisionBackgroundLayer.constructor.layerName, + 'collision background uses the expanded collision shader' + ).toBe('EnhancedTextBackgroundLayer'); + + expect( + visualBackgroundLayer.props.padding, + 'visual background uses TextLayer padding' + ).toEqual([5, 6, 7, 8]); + expect( + visualBackgroundLayer.props.getFillColor, + 'visual background color forwarded' + ).toEqual([255, 255, 255, 200]); + expect( + visualBackgroundLayer.props.getLineColor, + 'visual background border color forwarded' + ).toEqual([255, 0, 0, 255]); + expect( + visualBackgroundLayer.props.getLineWidth, + 'visual background border width forwarded' + ).toBe(2); + expect( + visualBackgroundLayer.props.borderRadius, + 'visual background border radius forwarded' + ).toBe(4); + + expect( + textLayer.filterSubLayer({layer: visualBackgroundLayer, renderPass: 'draw'}), + 'visual background drawn in draw pass' + ).toBeTruthy(); + expect( + !textLayer.filterSubLayer({layer: collisionBackgroundLayer, renderPass: 'draw'}), + 'collision background not drawn in draw pass' + ).toBeTruthy(); + expect( + !textLayer.filterSubLayer({layer: visualBackgroundLayer, renderPass: 'collision'}), + 'visual background not drawn in collision pass' + ).toBeTruthy(); + expect( + textLayer.filterSubLayer({layer: collisionBackgroundLayer, renderPass: 'collision'}), + 'collision background drawn in collision pass' + ).toBeTruthy(); + } + }, { props: { data: FIXTURES.geojson, diff --git a/test/render/golden-images/point-label-layer-background.png b/test/render/golden-images/point-label-layer-background.png new file mode 100644 index 0000000000000000000000000000000000000000..4da8bb0372161c38b45a88ca6bc648548b79467f GIT binary patch literal 9873 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 [0, 0], + getText: () => 'PointLabelLayer', + getRadius: 0, + sizeScale: 32, + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + getColor: [180, 0, 0], + background: true, + getBackgroundColor: [240, 250, 255], + getBorderWidth: 3, + getBorderColor: [0, 100, 180], + backgroundPadding: [12, 8], + backgroundBorderRadius: 8, + updateTriggers: {getText: 'point-label'}, + _subLayerProps: { + 'point-label-primary': {_getFontRenderer: () => fontRenderer} + } + }) + ], + goldenImage: './test/render/golden-images/point-label-layer-background.png' + }, { name: 'text-layer-auto-wrapping', viewState: {