Skip to content

feat(fetchMap): support polygon fill patterns - #313

Draft
zbigg wants to merge 17 commits into
feature/sc-555860-line-stroke-styles-fetchmapfrom
feature/fill-patterns-fetchmap
Draft

feat(fetchMap): support polygon fill patterns#313
zbigg wants to merge 17 commits into
feature/sc-555860-line-stroke-styles-fetchmapfrom
feature/fill-patterns-fetchmap

Conversation

@zbigg

@zbigg zbigg commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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 main once #297 lands.

Data-only fetchMap integration — parse-map emits accessors + flat props + scales; the consumer spreads descriptor.props and deck.gl does the rest. No @deck.gl/* imports.

  • getFillPatternAccessor (by-column) → ${pattern}-${density} keys; solid/none density-agnostic; unmapped → transparent none.
  • fillPatternEnabled toggle, atlas/mask props, getFillPattern + scales.fillPattern; tint rides the existing getFillColor. ScaleKey += fillPattern; new FillPatternRange + visConfig/visualChannels types.

Fill-pattern atlas — editable tiles assembled at runtime (+ baked comparison)

deck.gl's fillPatternAtlas/fillPatternMapping are async props (they accept a Promise), resolved internally (same machinery as data/iconAtlas). So the atlas builder stays internalparse-map sets result.fillPatternAtlas = getPatternAtlas() and the consumer contract never changes (just spreads descriptor.props).

Two implementations behind a runtime switch:

  • B — runtime (the approach we want): the patterns live as individual, developer-editable assets — 22 real 64×64 tiles under src/fetch-map/patterns/*.png, one per pattern+density (+ solid) — not a compiled blob. tsup inlines each (dataurl loader); the assembler composites them into the 192×512 sheet on a canvas at first render, memoized as a Promise. Verified pixel-identical to the Design master (0 / 98304 px differ).
  • A — baked (comparison): the same sheet frozen to one base64 data URL. Sync, no runtime deps, but a compiled blob you can't edit.

Switch (PoC): globalThis.__CARTO_RUNTIME_ATLAS__ = true selects 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-map specs (69 pass, type-checked); full suite green; prettier + eslint clean.

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +658 to +660
// Single mode: one real pattern (never solid/none) for every feature.
const key = `${visConfig.fillPattern}-${fillPatternDensity ?? 'medium'}`;
result.getFillPattern = () => key;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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;

zbigg and others added 15 commits July 9, 2026 12:21
…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.
@zbigg
zbigg force-pushed the feature/fill-patterns-fetchmap branch 2 times, most recently from 7483dff to 99a5107 Compare July 31, 2026 13:16
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>
@zbigg
zbigg force-pushed the feature/fill-patterns-fetchmap branch from 99a5107 to fb236de Compare July 31, 2026 14:05
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.

1 participant