diff --git a/docs/api-reference/extensions/overview.md b/docs/api-reference/extensions/overview.md index 478e6b1c56d..2bb9a49524a 100644 --- a/docs/api-reference/extensions/overview.md +++ b/docs/api-reference/extensions/overview.md @@ -16,6 +16,7 @@ This module contains the following extensions: - [Fp64Extension](./fp64-extension.md) - [MaskExtension](./mask-extension.md) - [PathStyleExtension](./path-style-extension.md) +- [StrokeStyleExtension](./stroke-style-extension.md) - [TerrainExtension](./terrain-extension.md) For instructions on authoring your own layer extensions, visit [developer guide](../../developer-guide/custom-layers/layer-extensions.md). diff --git a/docs/api-reference/extensions/stroke-style-extension.md b/docs/api-reference/extensions/stroke-style-extension.md new file mode 100644 index 00000000000..97bf8c1385f --- /dev/null +++ b/docs/api-reference/extensions/stroke-style-extension.md @@ -0,0 +1,156 @@ + +# StrokeStyleExtension + +The `StrokeStyleExtension` adds the capability to render dashed strokes on layers that use SDF-based stroke rendering. It supports: + +- [ScatterplotLayer](../layers/scatterplot-layer.md) - dashed circle strokes +- TextBackgroundLayer (used internally by [TextLayer](../layers/text-layer.md)) - dashed rectangle strokes (including rounded corners) + +```js +import {ScatterplotLayer} from '@deck.gl/layers'; +import {StrokeStyleExtension} from '@deck.gl/extensions'; + +const layer = new ScatterplotLayer({ + id: 'scatterplot-layer', + data, + stroked: true, + filled: true, + getPosition: d => d.coordinates, + getRadius: d => d.radius, + getFillColor: [255, 200, 0], + getLineColor: [0, 0, 0], + getDashArray: [3, 2], + dashGapPickable: false, + extensions: [new StrokeStyleExtension({dash: true})] +}); +``` + +## Installation + +To install the dependencies from NPM: + +```bash +npm install deck.gl +# or +npm install @deck.gl/core @deck.gl/layers @deck.gl/extensions +``` + +```js +import {StrokeStyleExtension} from '@deck.gl/extensions'; +new StrokeStyleExtension({dash: true}); +``` + +To use pre-bundled scripts: + +```html + + + + + +``` + +```js +new deck.StrokeStyleExtension({dash: true}); +``` + +## Constructor + +```js +new StrokeStyleExtension({dash}); +``` + +* `dash` (boolean) - add capability to render dashed strokes. Default `true`. + +## Layer Properties + +When added to a layer via the `extensions` prop, the `StrokeStyleExtension` adds the following properties to the layer: + +#### `getDashArray` ([Accessor<number[2]>](../../developer-guide/using-layers.md#accessors)) {#getdasharray} + +The dash array to draw each stroke with: `[dashSize, gapSize]` relative to the width of the stroke. + +* If an array is provided, it is used as the dash array for all objects. +* If a function is provided, it is called on each object to retrieve its dash array. Return `[0, 0]` to draw the stroke as a solid line. +* Default: `[0, 0]` (solid line) + +#### `dashGapPickable` (boolean, optional) {#dashgappickable} + +* Default: `false` + +Only effective if `getDashArray` is specified. If `true`, gaps between solid strokes are pickable. If `false`, only the solid strokes are pickable. + +## Supported Layers + +### ScatterplotLayer + +For `ScatterplotLayer`, the dash pattern is calculated based on the **angle around the circle's circumference**. The stroke must be enabled via `stroked: true`. + +```js +new ScatterplotLayer({ + data, + stroked: true, + filled: true, + getPosition: d => d.coordinates, + getRadius: 100, + getLineColor: [0, 0, 0], + getLineWidth: 2, + getDashArray: [3, 2], // 3 units solid, 2 units gap + extensions: [new StrokeStyleExtension({dash: true})] +}); +``` + +When a circle is both stroked and filled, the fill color shows through the gaps in the dash pattern. When only stroked (no fill), fragments in gaps are discarded. + +### TextBackgroundLayer + +For `TextBackgroundLayer`, the dash pattern is calculated based on the **perimeter position around the rectangle**. This includes proper handling of rounded corners when `borderRadius` is set. + +```js +new TextBackgroundLayer({ + data, + getPosition: d => d.coordinates, + getBoundingRect: d => d.bounds, + getLineColor: [0, 0, 0], + getLineWidth: 2, + borderRadius: 8, // Dashes will flow smoothly around corners + getDashArray: [4, 2], + extensions: [new StrokeStyleExtension({dash: true})] +}); +``` + +The perimeter calculation accounts for: +- Straight edges (reduced by corner radii) +- Corner arcs (quarter circles based on border radius) +- Different radii for each corner (when `borderRadius` is specified as `[topRight, bottomRight, bottomLeft, topLeft]`) + +## Comparison with PathStyleExtension + +| Feature | PathStyleExtension | StrokeStyleExtension | +|---------|-------------------|---------------------| +| **Target Layers** | PathLayer, PolygonLayer, GeoJsonLayer | ScatterplotLayer, TextBackgroundLayer | +| **getDashArray** | ✓ | ✓ | +| **dashGapPickable** | ✓ | ✓ | +| **getOffset** | ✓ | ✗ | +| **dashJustified** | ✓ | ✗ | +| **highPrecisionDash** | ✓ | ✗ | +| **Rendering Method** | Path geometry extrusion | SDF (Signed Distance Field) | + +Use `PathStyleExtension` for path-based layers and `StrokeStyleExtension` for SDF-based layers. + +## Remarks + +### How It Works + +Unlike `PathStyleExtension` which works with path geometry, `StrokeStyleExtension` works with layers that render strokes using Signed Distance Fields (SDF) in the fragment shader: + +- **ScatterplotLayer**: Uses angle-based position calculation. The position along the stroke is determined by the angle from the circle's center (0 to 2π radians). +- **TextBackgroundLayer**: Uses perimeter-based position calculation. The position is traced clockwise around the rectangle's perimeter, including arc lengths for rounded corners. + +### Performance + +The extension uses shader injection to add dash calculations. Each supported layer type receives only its specific shader code at compile time, so there is no runtime overhead from supporting multiple layer types. + +## Source + +[modules/extensions/src/stroke-style](https://github.com/visgl/deck.gl/tree/master/modules/extensions/src/stroke-style) diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index 1f1cb75b309..d3e7bf5de81 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -271,6 +271,7 @@ "api-reference/extensions/fp64-extension", "api-reference/extensions/mask-extension", "api-reference/extensions/path-style-extension", + "api-reference/extensions/stroke-style-extension", "api-reference/extensions/terrain-extension" ] }, diff --git a/examples/stroke-style-test/app.js b/examples/stroke-style-test/app.js new file mode 100644 index 00000000000..366096ab10c --- /dev/null +++ b/examples/stroke-style-test/app.js @@ -0,0 +1,161 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Deck} from '@deck.gl/core'; +import {ScatterplotLayer, _TextBackgroundLayer as TextBackgroundLayer} from '@deck.gl/layers'; +import {StrokeStyleExtension} from '@deck.gl/extensions'; + +const INITIAL_VIEW_STATE = { + latitude: 37.78, + longitude: -122.4, + zoom: 12, + bearing: 0, + pitch: 0 +}; + +// Fill-only circle for size comparison (no stroke) +const FILL_ONLY_DATA = [ + { + position: [-122.42, 37.79], + radius: 300, + fillColor: [255, 200, 0, 180] + } +]; + +// Sample data for ScatterplotLayer - circles at different positions +const SCATTERPLOT_DATA = [ + // Dashed stroke, filled - same radius as fill-only for comparison + { + position: [-122.41, 37.79], + radius: 300, + dashArray: [3, 2], + fillColor: [255, 200, 0, 180], + lineColor: [0, 100, 200] + }, + { + position: [-122.4, 37.79], + radius: 250, + dashArray: [5, 2], + fillColor: [100, 255, 100, 180], + lineColor: [0, 0, 0] + }, + { + position: [-122.39, 37.79], + radius: 200, + dashArray: [2, 1], + fillColor: [255, 100, 100, 180], + lineColor: [50, 50, 150] + }, + // Solid stroke for comparison + { + position: [-122.38, 37.79], + radius: 200, + dashArray: [0, 0], + fillColor: [200, 200, 255, 180], + lineColor: [100, 0, 100] + }, + // Dashed stroke, stroked only (no fill) + { + position: [-122.41, 37.78], + radius: 300, + dashArray: [4, 3], + fillColor: [0, 0, 0, 0], + lineColor: [255, 0, 0] + }, + { + position: [-122.4, 37.78], + radius: 250, + dashArray: [6, 2], + fillColor: [0, 0, 0, 0], + lineColor: [0, 150, 0] + } +]; + +// Sample data for TextBackgroundLayer - rectangles at different positions +const TEXT_BG_DATA = [ + // Large rounded rectangle - prominent example + { + position: [-122.395, 37.77], + bounds: [-120, -50, 120, 50], + borderRadius: 25, + dashArray: [4, 2], + lineColor: [0, 100, 200] + }, + // Sharp corners + { + position: [-122.41, 37.76], + bounds: [-60, -20, 60, 20], + borderRadius: 0, + dashArray: [4, 2], + lineColor: [0, 0, 0] + }, + // Rounded corners + { + position: [-122.4, 37.76], + bounds: [-50, -25, 50, 25], + borderRadius: 12, + dashArray: [3, 2], + lineColor: [200, 0, 100] + }, + // Solid stroke for comparison + { + position: [-122.39, 37.76], + bounds: [-40, -20, 40, 20], + borderRadius: 8, + dashArray: [0, 0], + lineColor: [100, 100, 100] + } +]; + +new Deck({ + initialViewState: INITIAL_VIEW_STATE, + controller: true, + layers: [ + // Fill-only circle for size comparison + new ScatterplotLayer({ + id: 'scatterplot-fill-only', + data: FILL_ONLY_DATA, + getPosition: d => d.position, + getRadius: d => d.radius, + getFillColor: d => d.fillColor, + stroked: false, + filled: true, + pickable: true, + autoHighlight: true + }), + + // ScatterplotLayer with dashed strokes + new ScatterplotLayer({ + id: 'scatterplot-dashed', + data: SCATTERPLOT_DATA, + getPosition: d => d.position, + getRadius: d => d.radius, + getFillColor: d => d.fillColor, + getLineColor: d => d.lineColor, + getDashArray: d => d.dashArray, + stroked: true, + filled: true, + lineWidthMinPixels: 4, + pickable: true, + autoHighlight: true, + extensions: [new StrokeStyleExtension({dash: true})] + }), + + // TextBackgroundLayer with dashed strokes + new TextBackgroundLayer({ + id: 'text-bg-dashed', + data: TEXT_BG_DATA, + getPosition: d => d.position, + getBoundingRect: d => d.bounds, + getBorderRadius: d => d.borderRadius, + getLineColor: d => d.lineColor, + getFillColor: [255, 255, 255, 200], + getLineWidth: 2, + getDashArray: d => d.dashArray, + pickable: true, + autoHighlight: true, + extensions: [new StrokeStyleExtension({dash: true})] + }) + ] +}); diff --git a/examples/stroke-style-test/index.html b/examples/stroke-style-test/index.html new file mode 100644 index 00000000000..00baeac8a05 --- /dev/null +++ b/examples/stroke-style-test/index.html @@ -0,0 +1,13 @@ + + + + + StrokeStyleExtension Test + + + + + + diff --git a/examples/stroke-style-test/package.json b/examples/stroke-style-test/package.json new file mode 100644 index 00000000000..6d8b39bcf1e --- /dev/null +++ b/examples/stroke-style-test/package.json @@ -0,0 +1,19 @@ +{ + "name": "deckgl-example-stroke-style-test", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.mjs", + "build": "vite build" + }, + "dependencies": { + "@deck.gl/core": "^9.0.0", + "@deck.gl/layers": "^9.0.0", + "@deck.gl/extensions": "^9.0.0" + }, + "devDependencies": { + "vite": "^4.0.0" + } +} diff --git a/modules/extensions/src/index.ts b/modules/extensions/src/index.ts index 048446e0870..2c14fe5f611 100644 --- a/modules/extensions/src/index.ts +++ b/modules/extensions/src/index.ts @@ -6,6 +6,7 @@ export {default as BrushingExtension} from './brushing/brushing-extension'; export {default as DataFilterExtension} from './data-filter/data-filter-extension'; export {default as Fp64Extension} from './fp64/fp64-extension'; export {default as PathStyleExtension} from './path-style/path-style-extension'; +export {default as StrokeStyleExtension} from './stroke-style/stroke-style-extension'; export {default as FillStyleExtension} from './fill-style/fill-style-extension'; export {default as ClipExtension} from './clip/clip-extension'; export {default as CollisionFilterExtension} from './collision-filter/collision-filter-extension'; @@ -25,6 +26,10 @@ export type { PathStyleExtensionProps, PathStyleExtensionOptions } from './path-style/path-style-extension'; +export type { + StrokeStyleExtensionProps, + StrokeStyleExtensionOptions +} from './stroke-style/stroke-style-extension'; export type { FillStyleExtensionProps, FillStyleExtensionOptions diff --git a/modules/extensions/src/stroke-style/shaders.glsl.ts b/modules/extensions/src/stroke-style/shaders.glsl.ts new file mode 100644 index 00000000000..50ef55029db --- /dev/null +++ b/modules/extensions/src/stroke-style/shaders.glsl.ts @@ -0,0 +1,281 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// Shader injections for ScatterplotLayer (circle-based dash calculation) +export const scatterplotDashShaders = { + inject: { + 'vs:#decl': ` +in vec2 instanceDashArrays; +out vec2 vDashArray; +`, + + 'vs:#main-end': ` +vDashArray = instanceDashArrays; +`, + + 'fs:#decl': ` +uniform strokeStyleUniforms { + bool dashGapPickable; +} strokeStyle; + +in vec2 vDashArray; + +#ifndef PI +#define PI 3.141592653589793 +#endif + +// Flag to track if current fragment is in a dash gap (for filled circles) +bool strokeStyle_inDashGap = false; +`, + + // Calculate if we're in a dash gap + // For unfilled circles: discard in gaps + // For filled circles: set flag to override color at end of main + 'fs:#main-start': ` +{ + float strokeStyle_solidLength = vDashArray.x; + float strokeStyle_gapLength = vDashArray.y; + float strokeStyle_unitLength = strokeStyle_solidLength + strokeStyle_gapLength; + + if (strokeStyle_unitLength > 0.0 && scatterplot.stroked > 0.5) { + float strokeStyle_distToCenter = length(unitPosition) * outerRadiusPixels; + float strokeStyle_innerRadius = innerUnitRadius * outerRadiusPixels; + + // Only check dash if we're in the stroke area + if (strokeStyle_distToCenter >= strokeStyle_innerRadius) { + // Calculate stroke width in pixels + float strokeStyle_strokeWidth = (1.0 - innerUnitRadius) * outerRadiusPixels; + // Calculate the radius at the center of the stroke + float strokeStyle_midStrokeRadius = (innerUnitRadius + 1.0) * 0.5 * outerRadiusPixels; + // Calculate angle from unit position (0 to 2*PI) + float strokeStyle_angle = atan(unitPosition.y, unitPosition.x) + PI; + // Calculate position along circumference in stroke-width units + float strokeStyle_circumference = 2.0 * PI * strokeStyle_midStrokeRadius; + float strokeStyle_positionAlongStroke = (strokeStyle_angle / (2.0 * PI)) * strokeStyle_circumference / strokeStyle_strokeWidth; + // Determine if in gap + float strokeStyle_unitOffset = mod(strokeStyle_positionAlongStroke, strokeStyle_unitLength); + if (strokeStyle_unitOffset > strokeStyle_solidLength) { + // In dash gap + if (scatterplot.filled > 0.5) { + // Filled circle - mark for fill color override at end of main + strokeStyle_inDashGap = true; + } else { + // Unfilled circle - discard unless picking gaps + if (!(strokeStyle.dashGapPickable && bool(picking.isActive))) { + discard; + } + } + } + } + } +} +`, + + // Override stroke color with fill color in dash gaps for filled circles + 'fs:#main-end': ` +if (strokeStyle_inDashGap) { + // In dash gap of filled circle - show fill color instead of stroke + // Preserve the antialiasing factor (inCircle) that was applied to alpha + float strokeStyle_alphaFactor = fragColor.a / max(vLineColor.a, 0.001); + fragColor = vec4(vFillColor.rgb, vFillColor.a * strokeStyle_alphaFactor); + // Re-apply highlight since we're after DECKGL_FILTER_COLOR + fragColor = picking_filterHighlightColor(fragColor); +} +` + } +}; + +// Shader injections for TextBackgroundLayer (rectangle-based dash calculation) +export const textBackgroundDashShaders = { + inject: { + 'vs:#decl': ` +in vec2 instanceDashArrays; +out vec2 vDashArray; +`, + + 'vs:#main-end': ` +vDashArray = instanceDashArrays; +`, + + 'fs:#decl': ` +uniform strokeStyleUniforms { + bool dashGapPickable; +} strokeStyle; + +in vec2 vDashArray; + +#ifndef PI +#define PI 3.141592653589793 +#endif + +// Calculate position along rounded rectangle perimeter (0 to perimeter length) +// Accounts for corner arcs when borderRadius > 0 +// Starting from bottom-left corner, going clockwise (up the left edge first) +float strokeStyle_getPerimeterPosition(vec2 fragUV, vec2 dims, vec4 radii, float lineWidth) { + float width = dims.x; + float height = dims.y; + + // Get effective border radius for each corner (clamped to max possible) + float maxRadius = min(width, height) * 0.5; + float rBL = min(radii.w, maxRadius); + float rTL = min(radii.z, maxRadius); + float rTR = min(radii.x, maxRadius); + float rBR = min(radii.y, maxRadius); + + // Pixel position from bottom-left corner + vec2 p = fragUV * dims; + + // Calculate perimeter segment lengths + float leftLen = height - rBL - rTL; + float topLen = width - rTL - rTR; + float rightLen = height - rTR - rBR; + float bottomLen = width - rBR - rBL; + + float arcBL = PI * 0.5 * rBL; + float arcTL = PI * 0.5 * rTL; + float arcTR = PI * 0.5 * rTR; + float arcBR = PI * 0.5 * rBR; + + float pos = 0.0; + + // Use distance-based edge detection (fixes non-square rectangle issue) + float distLeft = p.x; + float distRight = width - p.x; + float distBottom = p.y; + float distTop = height - p.y; + float minDist = min(min(distLeft, distRight), min(distBottom, distTop)); + + // Check corner regions first, then edges + // Bottom-left corner + if (p.x < rBL && p.y < rBL) { + vec2 c = vec2(rBL, rBL); + vec2 d = p - c; + // Angle: 0 at bottom of arc, PI/2 at left of arc + // d points from center toward pixel + // At bottom: d = (0, -r), want angle = 0 + // At left: d = (-r, 0), want angle = PI/2 + float angle = atan(-d.x, -d.y); + pos = angle / (PI * 0.5) * arcBL; + } + // Top-left corner + else if (p.x < rTL && p.y > height - rTL) { + vec2 c = vec2(rTL, height - rTL); + vec2 d = p - c; + // At left: d = (-r, 0), want angle = 0 + // At top: d = (0, r), want angle = PI/2 + float angle = atan(d.y, -d.x); + pos = arcBL + leftLen + angle / (PI * 0.5) * arcTL; + } + // Top-right corner + else if (p.x > width - rTR && p.y > height - rTR) { + vec2 c = vec2(width - rTR, height - rTR); + vec2 d = p - c; + // At top: d = (0, r), want angle = 0 + // At right: d = (r, 0), want angle = PI/2 + float angle = atan(d.x, d.y); + pos = arcBL + leftLen + arcTL + topLen + angle / (PI * 0.5) * arcTR; + } + // Bottom-right corner + else if (p.x > width - rBR && p.y < rBR) { + vec2 c = vec2(width - rBR, rBR); + vec2 d = p - c; + // At right: d = (r, 0), want angle = 0 + // At bottom: d = (0, -r), want angle = PI/2 + float angle = atan(-d.y, d.x); + pos = arcBL + leftLen + arcTL + topLen + arcTR + rightLen + angle / (PI * 0.5) * arcBR; + } + // Left edge + else if (minDist == distLeft) { + pos = arcBL + clamp(p.y - rBL, 0.0, leftLen); + } + // Top edge + else if (minDist == distTop) { + pos = arcBL + leftLen + arcTL + clamp(p.x - rTL, 0.0, topLen); + } + // Right edge + else if (minDist == distRight) { + pos = arcBL + leftLen + arcTL + topLen + arcTR + clamp(height - rTR - p.y, 0.0, rightLen); + } + // Bottom edge + else { + pos = arcBL + leftLen + arcTL + topLen + arcTR + rightLen + arcBR + clamp(width - rBR - p.x, 0.0, bottomLen); + } + + // Convert to stroke-width units + return pos / lineWidth; +} + +// Simple rectangular perimeter calculation (no rounded corners) +float strokeStyle_getRectPerimeterPosition(vec2 uv, vec2 dims, float lineWidth) { + float width = dims.x; + float height = dims.y; + + // Distance from each edge (in pixels) + float distLeft = uv.x * width; + float distRight = (1.0 - uv.x) * width; + float distBottom = uv.y * height; + float distTop = (1.0 - uv.y) * height; + + // Find minimum distance to determine which edge we're closest to + float minDist = min(min(distLeft, distRight), min(distBottom, distTop)); + + // Calculate position along perimeter based on closest edge + // Going clockwise from bottom-left corner + float pos = 0.0; + + if (minDist == distLeft) { + pos = uv.y * height; + } else if (minDist == distTop) { + pos = height + uv.x * width; + } else if (minDist == distRight) { + pos = height + width + (1.0 - uv.y) * height; + } else { + pos = 2.0 * height + width + (1.0 - uv.x) * width; + } + + return pos / lineWidth; +} +`, + + // Calculate if we're in a dash gap and discard if so + 'fs:#main-start': ` +{ + float strokeStyle_solidLength = vDashArray.x; + float strokeStyle_gapLength = vDashArray.y; + float strokeStyle_unitLength = strokeStyle_solidLength + strokeStyle_gapLength; + + if (strokeStyle_unitLength > 0.0 && textBackground.stroked) { + // Calculate distance to edge + float strokeStyle_distToEdge; + bool strokeStyle_hasRoundedCorners = textBackground.borderRadius != vec4(0.0); + + if (strokeStyle_hasRoundedCorners) { + strokeStyle_distToEdge = round_rect(uv, dimensions, textBackground.borderRadius); + } else { + strokeStyle_distToEdge = rect(uv, dimensions); + } + + // Only check dash if we're in the stroke area (near the edge) + if (strokeStyle_distToEdge <= vLineWidth && strokeStyle_distToEdge >= 0.0) { + // Use appropriate perimeter calculation based on corner style + float strokeStyle_positionAlongStroke; + if (strokeStyle_hasRoundedCorners) { + strokeStyle_positionAlongStroke = strokeStyle_getPerimeterPosition(uv, dimensions, textBackground.borderRadius, vLineWidth); + } else { + strokeStyle_positionAlongStroke = strokeStyle_getRectPerimeterPosition(uv, dimensions, vLineWidth); + } + // Determine if in gap + float strokeStyle_unitOffset = mod(strokeStyle_positionAlongStroke, strokeStyle_unitLength); + if (strokeStyle_unitOffset > strokeStyle_solidLength) { + // In dash gap - discard unless picking gaps + if (!(strokeStyle.dashGapPickable && bool(picking.isActive))) { + discard; + } + } + } + } +} +` + } +}; diff --git a/modules/extensions/src/stroke-style/stroke-style-extension.ts b/modules/extensions/src/stroke-style/stroke-style-extension.ts new file mode 100644 index 00000000000..c0b1fe48afc --- /dev/null +++ b/modules/extensions/src/stroke-style/stroke-style-extension.ts @@ -0,0 +1,134 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {LayerExtension} from '@deck.gl/core'; +import {scatterplotDashShaders, textBackgroundDashShaders} from './shaders.glsl'; + +import type {Layer, LayerContext, Accessor, UpdateParameters} from '@deck.gl/core'; +import type {ShaderModule} from '@luma.gl/shadertools'; + +const defaultProps = { + getDashArray: {type: 'accessor', value: [0, 0]}, + dashGapPickable: false +}; + +type StrokeStyleProps = { + dashGapPickable: boolean; +}; + +type SupportedLayerType = 'scatterplot' | 'textBackground' | null; + +export type StrokeStyleExtensionProps = { + /** + * Accessor for the dash array to draw each stroke with: `[dashSize, gapSize]` relative to the stroke width. + * Requires the `dash` option to be on. + * @default [0, 0] + */ + getDashArray?: Accessor; + /** + * If `true`, gaps between solid strokes are pickable. If `false`, only the solid strokes are pickable. + * @default false + */ + dashGapPickable?: boolean; +}; + +export type StrokeStyleExtensionOptions = { + /** + * Add capability to render dashed strokes. + * @default true + */ + dash?: boolean; +}; + +/** + * Adds dash rendering capability to stroked layers. + * + * Supported layers: + * - ScatterplotLayer: Dashed circle strokes (angle-based calculation) + * - TextBackgroundLayer: Dashed rectangle strokes (perimeter-based calculation) + */ +export default class StrokeStyleExtension extends LayerExtension { + static defaultProps = defaultProps; + static extensionName = 'StrokeStyleExtension'; + + constructor({dash = true}: Partial = {}) { + super({dash}); + } + + /** + * Detect which layer type this is to use the appropriate shader injections + */ + private getLayerType(layer: Layer): SupportedLayerType { + const layerName = layer.constructor.name; + + // ScatterplotLayer detection + if (layerName === 'ScatterplotLayer' || 'radiusScale' in layer.props) { + return 'scatterplot'; + } + + // TextBackgroundLayer detection + if (layerName === 'TextBackgroundLayer' || 'getBoundingRect' in layer.props) { + return 'textBackground'; + } + + return null; + } + + isEnabled(layer: Layer): boolean { + return this.getLayerType(layer) !== null; + } + + getShaders(this: Layer, extension: this): any { + const layerType = extension.getLayerType(this); + if (!layerType || !extension.opts.dash) { + return null; + } + + // Select the appropriate shader injections based on layer type + const shaderInjections = + layerType === 'scatterplot' ? scatterplotDashShaders : textBackgroundDashShaders; + + const strokeStyle: ShaderModule = { + name: 'strokeStyle', + inject: shaderInjections.inject, + uniformTypes: { + dashGapPickable: 'i32' + } + }; + + return { + modules: [strokeStyle] + }; + } + + initializeState(this: Layer, context: LayerContext, extension: this) { + const attributeManager = this.getAttributeManager(); + if (!attributeManager || !extension.isEnabled(this)) { + return; + } + + if (extension.opts.dash) { + attributeManager.addInstanced({ + instanceDashArrays: {size: 2, accessor: 'getDashArray'} + }); + } + } + + updateState( + this: Layer, + params: UpdateParameters>, + extension: this + ) { + if (!extension.isEnabled(this)) { + return; + } + + if (extension.opts.dash) { + const strokeStyleProps: StrokeStyleProps = { + dashGapPickable: Boolean(this.props.dashGapPickable) + }; + this.setShaderModuleProps({strokeStyle: strokeStyleProps}); + } + } +} diff --git a/modules/layers/src/scatterplot-layer/scatterplot-layer-fragment.glsl.ts b/modules/layers/src/scatterplot-layer/scatterplot-layer-fragment.glsl.ts index d4780c28276..9f9ebd81f1c 100644 --- a/modules/layers/src/scatterplot-layer/scatterplot-layer-fragment.glsl.ts +++ b/modules/layers/src/scatterplot-layer/scatterplot-layer-fragment.glsl.ts @@ -21,7 +21,7 @@ void main(void) { float distToCenter = length(unitPosition) * outerRadiusPixels; float inCircle = scatterplot.antialiasing ? - smoothedge(distToCenter, outerRadiusPixels) : + smoothedge(distToCenter, outerRadiusPixels) : step(distToCenter, outerRadiusPixels); if (inCircle == 0.0) { @@ -29,7 +29,7 @@ void main(void) { } if (scatterplot.stroked > 0.5) { - float isLine = scatterplot.antialiasing ? + float isLine = scatterplot.antialiasing ? smoothedge(innerUnitRadius * outerRadiusPixels, distToCenter) : step(innerUnitRadius * outerRadiusPixels, distToCenter); diff --git a/test/modules/extensions/index.ts b/test/modules/extensions/index.ts index 14175ecd743..7cd9a0b2ba6 100644 --- a/test/modules/extensions/index.ts +++ b/test/modules/extensions/index.ts @@ -8,6 +8,7 @@ import './collision-filter'; import './clip.spec'; import './fp64.spec'; import './path.spec'; +import './stroke-style.spec'; import './fill-style.spec'; import './mask'; import './terrain'; diff --git a/test/modules/extensions/stroke-style.spec.ts b/test/modules/extensions/stroke-style.spec.ts new file mode 100644 index 00000000000..40dfb9e6a2b --- /dev/null +++ b/test/modules/extensions/stroke-style.spec.ts @@ -0,0 +1,154 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from 'tape-promise/tape'; +import {StrokeStyleExtension} from '@deck.gl/extensions'; +import {ScatterplotLayer, TextBackgroundLayer} from '@deck.gl/layers'; +import {getLayerUniforms, testLayer} from '@deck.gl/test-utils'; + +import * as FIXTURES from 'deck.gl-test/data'; + +test('StrokeStyleExtension#ScatterplotLayer', t => { + const testCases = [ + { + props: { + id: 'stroke-style-extension-test', + data: FIXTURES.points, + getPosition: d => d.COORDINATES, + getRadius: d => d.SPACES || 1, + stroked: true, + filled: true, + getDashArray: [0, 0], + extensions: [new StrokeStyleExtension({dash: true})] + }, + onAfterUpdate: ({layer}) => { + const uniforms = getLayerUniforms(layer); + t.is(uniforms.dashGapPickable, false, 'has dashGapPickable uniform'); + const attributes = layer.getAttributeManager().getAttributes(); + t.ok(attributes.instanceDashArrays, 'instanceDashArrays attribute exists'); + t.deepEqual( + attributes.instanceDashArrays.value.slice(0, 2), + [0, 0], + 'instanceDashArrays attribute is populated' + ); + } + }, + { + updateProps: { + dashGapPickable: true, + getDashArray: d => [3, 1], + updateTriggers: { + getDashArray: 1 + } + }, + onAfterUpdate: ({layer}) => { + const uniforms = getLayerUniforms(layer); + t.is(uniforms.dashGapPickable, true, 'dashGapPickable uniform is updated'); + const attributes = layer.getAttributeManager().getAttributes(); + t.deepEqual( + attributes.instanceDashArrays.value.slice(0, 2), + [3, 1], + 'instanceDashArrays attribute is updated' + ); + } + } + ]; + + testLayer({Layer: ScatterplotLayer, testCases, onError: t.notOk}); + + t.end(); +}); + +test('StrokeStyleExtension#TextBackgroundLayer', t => { + const textBackgroundData = [ + {position: [0, 0], text: 'Hello'}, + {position: [1, 1], text: 'World'} + ]; + + const testCases = [ + { + props: { + id: 'stroke-style-text-background-test', + data: textBackgroundData, + getPosition: d => d.position, + getBoundingRect: () => [0, 0, 100, 20], + getLineWidth: 2, + getDashArray: [4, 2], + extensions: [new StrokeStyleExtension({dash: true})] + }, + onAfterUpdate: ({layer}) => { + const uniforms = getLayerUniforms(layer); + t.is(uniforms.dashGapPickable, false, 'has dashGapPickable uniform'); + const attributes = layer.getAttributeManager().getAttributes(); + t.ok(attributes.instanceDashArrays, 'instanceDashArrays attribute exists'); + t.deepEqual( + attributes.instanceDashArrays.value.slice(0, 2), + [4, 2], + 'instanceDashArrays attribute is populated' + ); + } + }, + { + updateProps: { + dashGapPickable: true, + getDashArray: d => [2, 1], + updateTriggers: { + getDashArray: 1 + } + }, + onAfterUpdate: ({layer}) => { + const uniforms = getLayerUniforms(layer); + t.is(uniforms.dashGapPickable, true, 'dashGapPickable uniform is updated'); + const attributes = layer.getAttributeManager().getAttributes(); + t.deepEqual( + attributes.instanceDashArrays.value.slice(0, 2), + [2, 1], + 'instanceDashArrays attribute is updated' + ); + } + } + ]; + + testLayer({Layer: TextBackgroundLayer, testCases, onError: t.notOk}); + + t.end(); +}); + +test('StrokeStyleExtension#layer type detection', t => { + const extension = new StrokeStyleExtension({dash: true}); + + // Create mock layer objects + const scatterplotLayer = { + constructor: {name: 'ScatterplotLayer'}, + props: {radiusScale: 1} + }; + + const textBackgroundLayer = { + constructor: {name: 'TextBackgroundLayer'}, + props: {getBoundingRect: () => [0, 0, 100, 20]} + }; + + const unsupportedLayer = { + constructor: {name: 'OtherLayer'}, + props: {someOtherProp: true} + }; + + t.is( + extension.isEnabled(scatterplotLayer as any), + true, + 'isEnabled returns true for ScatterplotLayer' + ); + t.is( + extension.isEnabled(textBackgroundLayer as any), + true, + 'isEnabled returns true for TextBackgroundLayer' + ); + t.is( + extension.isEnabled(unsupportedLayer as any), + false, + 'isEnabled returns false for unsupported layers' + ); + + t.end(); +}); diff --git a/test/render/test-cases/core-layers.js b/test/render/test-cases/core-layers.js index 8e1c9dfd351..9a3444a6ad3 100644 --- a/test/render/test-cases/core-layers.js +++ b/test/render/test-cases/core-layers.js @@ -13,7 +13,7 @@ import { LineLayer } from '@deck.gl/layers'; -import {Fp64Extension} from '@deck.gl/extensions'; +import {Fp64Extension, StrokeStyleExtension} from '@deck.gl/extensions'; import * as dataSamples from 'deck.gl-test/data'; // prettier-ignore @@ -172,6 +172,63 @@ export default [ ], goldenImage: './test/render/golden-images/scatterplot-smoothedge.png' }, + { + name: 'scatterplot-dash', + viewState: { + latitude: 37.751537058389985, + longitude: -122.42694203247012, + zoom: 11.5, + pitch: 0, + bearing: 0 + }, + layers: [ + new ScatterplotLayer({ + id: 'scatterplot-dash', + data: dataSamples.points, + getPosition: d => d.COORDINATES, + getFillColor: [255, 200, 0, 128], + getLineColor: [0, 100, 200], + getRadius: d => d.SPACES, + getDashArray: [3, 2], + stroked: true, + filled: true, + radiusScale: 30, + radiusMinPixels: 10, + radiusMaxPixels: 50, + lineWidthMinPixels: 4, + extensions: [new StrokeStyleExtension({dash: true})] + }) + ], + goldenImage: './test/render/golden-images/scatterplot-dash.png' + }, + { + name: 'scatterplot-dash-stroked-only', + viewState: { + latitude: 37.751537058389985, + longitude: -122.42694203247012, + zoom: 11.5, + pitch: 0, + bearing: 0 + }, + layers: [ + new ScatterplotLayer({ + id: 'scatterplot-dash-stroked-only', + data: dataSamples.points, + getPosition: d => d.COORDINATES, + getLineColor: [200, 50, 100], + getRadius: d => d.SPACES, + getDashArray: [2, 1], + stroked: true, + filled: false, + radiusScale: 30, + radiusMinPixels: 10, + radiusMaxPixels: 50, + lineWidthMinPixels: 3, + extensions: [new StrokeStyleExtension({dash: true})] + }) + ], + goldenImage: './test/render/golden-images/scatterplot-dash-stroked-only.png' + }, { name: 'line-lnglat', viewState: {