feat(fetchMap): support polygon fill patterns - #313
Conversation
Phase 2 of line/polygon styling, stacked on the stroke-dash work (#297). Data only — parse-map computes fill-pattern accessors + flat props + scales; the consumer attaches FillStyleExtension. No @deck.gl imports. - Inline base64 sprite atlas (7 patterns x 3 densities + solid/none = 23 cells, mask:true) in pattern-atlas.ts, same mechanism as FALLBACK_ICON — no asset file, no HTTP/CORS surface. - getFillPatternAccessor (by-column) resolving `${pattern}-${density}` keys; solid/none stay density-agnostic; unmapped falls back to transparent none. - parse-map: fillPatternEnabled toggle, atlas/mask props, getFillPattern + scales.fillPattern; pattern tinted by the existing getFillColor. - ScaleKey union += 'fillPattern'; FillPatternRange + visConfig/visualChannels types. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces support for polygon fill patterns using an inlined base64 sprite atlas. It adds accessor functions, configuration types, and parser logic to handle both single and by-column fill pattern modes, along with comprehensive unit tests. A review comment identifies a critical issue in single mode where density is unconditionally appended to the pattern key, potentially producing invalid keys like 'solid-medium' or 'none-medium' that do not exist in the atlas mapping.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // Single mode: one real pattern (never solid/none) for every feature. | ||
| const key = `${visConfig.fillPattern}-${fillPatternDensity ?? 'medium'}`; | ||
| result.getFillPattern = () => key; |
There was a problem hiding this comment.
In single mode, if visConfig.fillPattern is 'solid', 'none', or undefined, constructing the key by unconditionally appending the density (e.g., ${visConfig.fillPattern}-${density}) will result in invalid keys like 'solid-medium', 'none-medium', or 'undefined-medium'. These keys do not exist in PATTERN_ATLAS_MAPPING and will cause rendering issues or sample the wrong atlas cell.
We should resolve the key using the same logic as getFillPatternAccessor (treating 'solid' and 'none' as density-agnostic, and falling back to 'none' if undefined).
// Single mode: one real pattern (never solid/none) for every feature.
const pattern = visConfig.fillPattern ?? 'none';
const key =
pattern === 'solid' || pattern === 'none'
? pattern
: pattern + '-' + (fillPatternDensity ?? 'medium');
result.getFillPattern = () => key;…C switch Adds a second atlas implementation alongside the baked data URL, plus a runtime switch to A/B them, per PoC request. - pattern-atlas-runtime.ts (option B): imports the vendored Meridian pattern SVGs (src/fetch-map/patterns/*.svg — consulted from meridian-ds, NOT a dependency), tsup inlines each as a data URL (dataurl loader), and composites them into the 192x512 sheet on a canvas the first time a pattern renders (memoized). Handed to deck.gl's async `fillPatternAtlas` prop as a Promise, so getPatternAtlas stays internal to api-client and the consumer contract (spread descriptor.props) is unchanged. - pattern-atlas-baked.ts (option A): the sheet frozen to a base64 data URL — now the frozen output of the runtime assembler over the same Meridian glyphs, so both options render identical art and the switch isolates blob-vs-assembly. - Switch (PoC): `globalThis.__CARTO_RUNTIME_ATLAS__ = true` selects runtime; default baked. - tsup `dataurl` loader for .svg + `*.svg` ambient decl. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the placeholder sheet with Design's real 192x512 atlas (proper space-filling tiles at 3 densities), and fixes the last-row mapping: `solid` is the middle cell (64,448); `none` points at a transparent cell (0,448). Source of truth is a vector master rendered to PNG and inlined as a data URL. The runtime assembler (option B) stays behind the PoC switch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tiles Reworks option B around the point of the proposal: the patterns ship as individual, developer-editable assets — 22 real 64x64 Design tiles under src/fetch-map/patterns/ (sliced from the Design master), one per pattern+density + solid — instead of a compiled base64 blob. tsup inlines each (dataurl loader), and the runtime assembler composites them into the 192x512 sheet on a canvas, memoized, handed to deck.gl's async `fillPatternAtlas` as a Promise. Verified pixel-identical to the Design atlas (0/98304 px differ). Replaces the earlier Meridian legend glyphs (which don't tile). SVG-vs-PNG for the asset format is a deferred experiment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ta URL deck.gl URL-loads string prop values but hands promise-resolved strings straight to texture creation, so luma.gl received the data-URL string as raw texel data (1x1 texture, 'ArrayBufferView not big enough'). Resolve to an ImageBitmap (canvas fallback) instead.
The tiles are pixel art at Nyquist frequency (checker-small is a 2-texel checker): linear filtering moirés into gray bands near 1:1 scale and blurs under magnification. textureParameters merges into the sampler deck.gl builds for the atlas texture; lodMaxClamp 0 is preserved.
Nearest minification picks one arbitrary texel per pixel, dissolving patterns into per-frame noise at zoomed-out views. Nearest stays for magnification only.
The runtime-assembled atlas won the A/B, so the baked sheet and its switch are gone. Tiles now upscale nearest-neighbor to a configurable cell size (default 128) at assembly, so each art pixel spans several texels — deck's default linear sampling then keeps edges crisp without the uneven line spacing nearest filtering showed at fractional zoom. Debug knobs: __CARTO_PATTERN_CELL_SIZE__ (cell px) and __CARTO_PATTERN_TEXTURE_PARAMS__ (sampler override, default linear).
…size deck's fill-pattern shader sizes the repeat proportionally to the mapping frame's texel size, so a bigger cell magnified the art instead of sharpening it. getFillPatternScale is now multiplied by 64/cellSize, making cell size a pure resolution knob.
…utters Tiles may now be any resolution (anti-aliased Design exports are the target); high-res sources downscale smoothly, pixel-art sources keep the nearest upscale. Each atlas cell gains a gutter filled with the tile's own wrapped content, so linear sampling is seamless across repeat boundaries and cannot bleed a neighboring cell.
The diagonal art (diag-*, cross-hatch) carries baked anti-aliasing that nearest upscaling turned into hard blocks — the jagged diagonals seen at high zoom. Axis-aligned art has exact edges on texel boundaries, where nearest is lossless and smoothing would blur.
Cells were filled by stretching the source tile (background-size: 100%, in CSS terms), resampling the raster; now they tile it (background- repeat: repeat) — reps x reps native copies, zero resampling, and the UV wrap where sampling seams live fires 1/reps as often. The cell-size scale adjustment cancels to exactly 1 for cells that are multiples of the 64px source tile.
…s, procedural
Three atlas sources behind __CARTO_PATTERN_ASSET_SOURCE__ ('png' default):
Design's original 64px raster masks tiled natively; in-repo vector tiles
(compact <pattern>-def SVGs on the 64px design grid) rasterized at
atlas-build time at 2x texel density; and asset-free canvas painters
drawing the same measured geometry. One builder per source feeding a
shared compositor; memoized per source+cell; on-screen size normalized
to the design grid across all sources.
Removing the pattern props on toggle-off transitions the async
fillPatternAtlas prop to null, which crashes deck's layer matching
('Cannot read properties of null (reading constructor)' in the image
prop transform) and blanks the layer in tile-layer stacks. Filled
layers now always carry atlas/mapping/mask; when the pattern is off the
accessor samples the opaque 'solid' cell (mask x 1 = plain fill). Also
adds the missing single-mode updateTriggers.getFillPattern so pattern/
density changes invalidate attributes on live layers.
Read the pattern atlas debug knobs (cell size, texture params, asset source) from localStorage first, falling back to globalThis. localStorage persists across reloads and is settable straight from devtools, so the knobs can be exercised on a deployed build with no source access or reload-timing dance. Values are parsed as JSON, or taken as a raw string when not valid JSON.
Turn the ad-hoc gutter into a principled bleeding buffer: the margin is sized
to 2^N atlas texels (N = getPatternMipLevels, default 4) so N mip levels stay
bleed-free, capped at cell/4. composeAtlas fills the whole margin with the
cell's wrapped pattern (extra repeat rings, not just one).
parse-map now emits textureParameters { lodMaxClamp: N } for patterned fills,
so deck's already-generated mip chain is used out of the box — killing the
zoomed-out Moiré — while staying within the bleed-free range. The debug knob
still wins (e.g. { lodMaxClamp: 0 } to disable).
New __CARTO_PATTERN_MIP_LEVELS__ knob + unit coverage for the margin sizing.
7483dff to
99a5107
Compare
Replace the localStorage-knob atlas with `_buildPatternAtlas({assetSource,
size, resolution, mipLevels})` returning {atlas, mapping, scaleAdjustment,
cell, mipLevels}, so consumers drive the atlas explicitly and own scale.
Two sources: svg (Figma export, default) and png; drop the procedural
painters and the reverse-engineered svg twins. size is the on-screen
reference, resolution is texel density only (png pinned to native 64); one
tile per cell, scaleAdjustment = size/cell keeps on-screen size invariant to
resolution. Default svg / 64 @ 2 / mip 4.
Figma svg tiles are SVGO-minified (148KB -> 34KB) and inlined as data URLs;
the bundle-size budget moves to 450KB to cover the vendored atlas, which must
ship without a dynamic import.
parse-map emits the plain, world-anchored pattern props (atlas, mapping,
lodMaxClamp, getFillPatternScale) with no zoom adaptation — that belongs to
the consumer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99a5107 to
fb236de
Compare
Phase 2 of line & polygon styling: polygon fill patterns. Stacked on #297 (stroke dash styles) so a temporary prerelease cut from this branch carries both. Retarget to
mainonce #297 lands.Data-only
fetchMapintegration —parse-mapemits accessors + flat props +scales; the consumer spreadsdescriptor.propsand deck.gl does the rest. No@deck.gl/*imports.getFillPatternAccessor(by-column) →${pattern}-${density}keys;solid/nonedensity-agnostic; unmapped → transparentnone.fillPatternEnabledtoggle, atlas/mask props,getFillPattern+scales.fillPattern; tint rides the existinggetFillColor.ScaleKey+=fillPattern; newFillPatternRange+ visConfig/visualChannels types.Fill-pattern atlas — editable tiles assembled at runtime (+ baked comparison)
deck.gl's
fillPatternAtlas/fillPatternMappingare async props (they accept aPromise), resolved internally (same machinery asdata/iconAtlas). So the atlas builder stays internal —parse-mapsetsresult.fillPatternAtlas = getPatternAtlas()and the consumer contract never changes (just spreadsdescriptor.props).Two implementations behind a runtime switch:
src/fetch-map/patterns/*.png, one per pattern+density (+solid) — not a compiled blob. tsup inlines each (dataurlloader); the assembler composites them into the 192×512 sheet on a canvas at first render, memoized as aPromise. Verified pixel-identical to the Design master (0 / 98304 px differ).Switch (PoC):
globalThis.__CARTO_RUNTIME_ATLAS__ = trueselects B; default A. Both render identical Design art.The asset format (PNG now vs SVG later) is a deferred experiment; the vector master is kept out of the repo for now.
Tests:
parse-map+layer-mapspecs (69 pass, type-checked); full suite green; prettier + eslint clean.