Skip to content

Fix multiscale labels stretching when zoomed out past the coarsest level - #71

Merged
xinaesthete merged 1 commit into
mainfrom
claude/gracious-einstein-e4d845
Jun 29, 2026
Merged

Fix multiscale labels stretching when zoomed out past the coarsest level#71
xinaesthete merged 1 commit into
mainfrom
claude/gracious-einstein-e4d845

Conversation

@xinaesthete

@xinaesthete xinaesthete commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

Multiscale labels rendered with an obviously wrong, vertically-stretched, mis-placed transformation once you zoomed out past the deepest resolution level of the pyramid. This is the recurrence of the "labels switch to a wrong transformation at certain zoom levels" bug that #44 was thought to fix.

Why it happened

MultiscaleLabelsTileLayer was configured with minZoom: -20. deck.gl therefore kept subdividing the tile grid below the coarsest level that actually exists (e.g. a 4-level pyramid only has tiles down to z = −3):

  • getTileData clamps resolution to the deepest loader and returns the same data (a full tileSize tile).
  • But the tile bbox keeps doubling as z goes more negative (bottom = 4096 → 8192 → 16384 …).
  • Because the data fills a full tile, the "partial-edge" clamp in the bounds formula doesn't fire, so bounds.bottom = bbox.bottom — the inflated value. That fixed strip of label data gets stretched across a world rect far taller than the image.

This only triggers below a certain zoom, which is exactly the reported symptom. #44 (unique sublayer ids) only masked a separate stale-texture artifact.

Fix

  • Cap minZoom at Math.round(-(loader.length - 1)), matching Viv's MultiscaleImageLayer. deck.gl then stops subdividing past the coarsest real level and keeps those tiles correctly placed at any zoom-out.
  • Add the bbox-culling guards Viv's renderSubLayers applies (skip tiles with negative bbox edges or zero-sized data) as defense in depth.

Diagnosis notes

Confirmed in the running demo by instrumenting renderSubBitmaskLayers and reading the actual per-tile bounds + model-matrix world rects: at z = −3 the two tiles tile world-y 0 → 14950 → 19264 exactly (correct), while at z < −3 the same data inflated to world-y far beyond the image height — the stretch. The fix was verified visually against the 1113PMDC1_human_01 sample (H&E + cell_labels).

Testing

  • New unit tests assert minZoom === -(levels - 1) for 2- and 4-level loaders, plus culling of out-of-extent / zero-sized tiles.
  • pnpm --filter @spatialdata/layers test → 46 passing; build (vite + tsc --noEmit) clean.

Reviewer notes

  • MIN_LABELS_DISPLAY_ZOOM is retained — still used by the single-scale path.
  • Unrelated observation: @spatialdata/layers has no dev (vite build --watch) script, unlike core/react/zarrextra. The demo works because it aliases the package to source, but consumers reading the built dist won't see layer edits during pnpm dev without a manual build. Not addressed here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed multiscale label rendering at very low zoom levels so labels stay aligned instead of stretching or shifting.
    • Improved tile filtering to skip out-of-bounds or empty tiles, reducing visual artifacts and unexpected rendering issues.
  • Tests

    • Added coverage for low-zoom label behavior, out-of-bounds tile handling, and empty tile data cases.

The MultiscaleLabelsTileLayer was created with minZoom: -20, so deck.gl
kept subdividing the tile grid below the deepest available resolution
level. Past that level getTileData clamps to the deepest loader and
returns the same data, but the tile bbox keeps doubling — so the bounds
formula stretched that fixed data across an ever-larger world rect, far
beyond the image extent. This is the "labels switch to an obviously wrong
transformation at certain zoom levels" bug (PR #44 only masked a related
texture-reuse artifact via unique sublayer ids).

Cap minZoom at -(loader.length - 1), matching Viv's MultiscaleImageLayer,
so the coarsest real tiles stay correctly placed at any zoom-out. Also add
the bbox-culling guards Viv's renderSubLayers applies (skip negative-edge
or zero-sized tiles) for defense in depth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 20ce2dc0-baad-44aa-86ed-8ff0b9b07dc7

📥 Commits

Reviewing files that changed from the base of the PR and between 25124c5 and 06f2f39.

📒 Files selected for processing (3)
  • .changeset/cull-out-of-bounds-label-tiles.md
  • packages/layers/src/LabelsLayer.ts
  • packages/layers/tests/labelsLayer.spec.ts

📝 Walkthrough

Walkthrough

MultiscaleLabelsTileLayer gains a computed minZoom capped at -(loader.length - 1) to prevent deck.gl from subdividing beyond available resolutions. renderSubBitmaskLayers adds early-return guards for tiles with negative bbox edges or zero-sized raster data. Three unit tests and a patch changeset entry accompany the fix.

Changes

MultiscaleLabel tile culling and minZoom cap

Layer / File(s) Summary
minZoom cap and bbox/empty-data culling
packages/layers/src/LabelsLayer.ts
renderSubBitmaskLayers returns null early for tiles with negative bbox edges or zero data.width/data.height; multiscale renderLayers computes minZoom = -(loader.length - 1) and passes it to the sublayer instead of MIN_LABELS_DISPLAY_ZOOM.
Unit tests and changeset
packages/layers/tests/labelsLayer.spec.ts, .changeset/cull-out-of-bounds-label-tiles.md
Three new tests verify minZoom capping for 2-level and 4-level loaders, bbox-based culling (null for out-of-bounds, truthy for in-bounds), and zero-sized-data culling. Patch changeset documents the fix.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Poem

🐇 Hop hop, the tiles were going astray,
Zooming out made the labels fray.
A minZoom cap keeps them in line,
Negative bbox? Skip — you're not fine!
Zero-sized data? Null, goodbye!
The labels now render, neat and spry. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix for multiscale label stretching at low zoom levels.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/gracious-einstein-e4d845

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@xinaesthete
xinaesthete merged commit bd594e2 into main Jun 29, 2026
2 checks passed
@xinaesthete
xinaesthete deleted the claude/gracious-einstein-e4d845 branch June 29, 2026 15:09
@github-actions github-actions Bot mentioned this pull request Jun 29, 2026
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