Skip to content

feat(map): switch to maplibre native#1003

Merged
escapedcat merged 117 commits into
mainfrom
feat/maplibre-native
May 27, 2026
Merged

feat(map): switch to maplibre native#1003
escapedcat merged 117 commits into
mainfrom
feat/maplibre-native

Conversation

@escapedcat

@escapedcat escapedcat commented May 13, 2026

Copy link
Copy Markdown
Contributor

Re-writing all ze maps to solve #996 🤞

  • Map switched from Leaflet to MapLibre — smoother pan/zoom, rotation + pitch, modern WebGL rendering
  • /merchant/[id], /add-location, /communities/map, area + saved-place maps all migrated too — consistent look across the site
  • Browsers without WebGL get an explanatory fallback instead of a blank map
  • Three selectable basemaps via the layer picker (OSM, Carto Light, Carto Dark)
  • Dark-mode treatment for the control pill, layer icon, and community popup
  • Boosted listings stay individually visible at every zoom (no longer absorbed into clusters) and correctly show their comment-count badge, saved-bookmark badge, and place-name label
  • Community / country / saved-places maps now cluster pins at low zoom so dense cities aren't a wall of overlapping pins
  • Map pins filter with the panel — picking a category or running a search narrows the map's pins to match the list
  • Boosting a place or leaving a comment updates that pin on the map immediately
  • Searching a place zooms close enough to actually see the pin instead of stopping at a cluster
  • "Locate me" eases straight to your position instead of flying a parabolic arc
  • Returning visitors land back where they left off; first-time visitors get an IP-derived starting view
  • Theme toggle preserves your pan/zoom
  • Sync errors surface as a toast instead of silently leaving the map empty
  • Bad URL coordinates show a toast instead of silently defaulting
  • Embed contract preserved: ?lat=&long= (single point + bounds-pair) still works; deep-linked street-level zoom (21) works again everywhere
  • Scale bar restored; "Support BTC Map" link restored in the attribution
  • Navigating community → community shows the new area's pins immediately; the merchant drawer closes if its place isn't in the new area
  • Multipart communities render the full boundary (previously only the first piece showed)
  • /communities/map's basemap picker is properly styled now (was unstyled fallback)
  • Search-radius math handles the antimeridian — searches around Fiji / NZ / eastern Russia no longer ask the API for half the planet
  • Map doesn't repeat horizontally at low zoom

Summary by CodeRabbit

  • New Features

    • Full migration from Leaflet to MapLibre GL with selectable basemaps, new map controls (basemap picker, boost toggle, nav buttons, data refresh), improved pin/sprite rendering, URL hash viewport sync, and refined hover/preview behavior.
  • Refactor

    • Map-related UI and interactions reimplemented for MapLibre, simplifying lifecycle and style switching.
  • Bug Fixes

    • More consistent cluster/marker rendering, theme switching, and popup/cleanup handling.
  • Tests

    • End-to-end tests updated for MapLibre DOM and WebGL rendering.

Review Change Stack

@netlify

netlify Bot commented May 13, 2026

Copy link
Copy Markdown

Deploy Preview for btcmap ready!

Name Link
🔨 Latest commit 338e8e4
🔍 Latest deploy log https://app.netlify.com/projects/btcmap/deploys/6a16f354b3a8410008a1c100
😎 Deploy Preview https://deploy-preview-1003--btcmap.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 52 (🔴 down 38 from production)
Accessibility: 97 (no change from production)
Best Practices: 92 (🔴 down 8 from production)
SEO: 96 (no change from production)
PWA: 90 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Migrate mapping from Leaflet to MapLibre GL across the codebase: update dependencies/types, add basemap/hash/viewport utilities and sprite baking, implement MapLibre controls, rewrite all map components/pages to use GeoJSON sources/layers, remove Leaflet utilities, and update tests/helpers.

Changes

Leaflet to MapLibre GL Migration

Layer / File(s) Summary
Dependencies & types
package.json, src/lib/types.ts, src/types/leaflet-dom.d.ts, src/routes/+layout.svelte
Remove Leaflet deps/types, bump maplibre-gl to ^5.24.0, add @nazka/map-gl-js-spiderfy, and adjust layout imports.
Material icon resolver
src/lib/materialIcons.ts, src/components/Icon.svelte
Centralize Material/Iconify name resolution (materialExceptions, resolveMaterialIcon) and update Icon component to use it.
Basemaps, hash, viewport utilities
src/lib/map/basemaps.ts, src/lib/map/mapHash.ts, src/lib/map/viewport.ts
Add basemap catalog with localStorage, parse/write URL hash for viewport (zoom/lat/lng/bearing/pitch), and compute radius from MapLibre LngLatBounds (with tests).
MapLibre sprite system
src/lib/map/maplibreSprites.ts
Implement sprite baking: fetch Iconify SVGs, composite into pin SVGs (regular/boosted), rasterize at higher DPR, de-duplicate per-map creation, placeholder handler, and bulk ensure helper.
Remove Leaflet utilities
Deleted: src/lib/map/batch-processor.ts, src/lib/map/imports.ts, src/lib/map/labels.ts, src/lib/map/marker-creation.ts, src/lib/map/markers.ts, src/lib/map/setup.ts
Delete Leaflet marker/label/batch/setup utilities no longer needed after migration.
Map controls & CSS
src/routes/map/controls/*, src/routes/map/controls/controls.css
Add MapLibre IControl implementations (BasemapsControl, BoostToggleControl, DataRefreshControl, NavButtonsControl) and shared controls CSS.
Main map & MultiPlaceMap
src/routes/map/+page.svelte, src/components/MultiPlaceMap.svelte
Replace Leaflet clustering/markers with MapLibre clustered GeoJSON sources/layers, spiderfy/hull hover, unclustered/boosted pins, sprite-based icons, style swaps, and URL hash viewport persistence and deep-linking.
AreaMap component
src/components/area/AreaMap.svelte
Rewrite AreaMap to MapLibre: area outline and clustered places via GeoJSON sources/layers, comment badge sprite, MapLibre interactions, and reactive theme handling.
CommunityRail & Communities map
src/components/CommunityRail.svelte, src/routes/communities/map/+page.svelte
Refactor hover preview to MapLibre GeoJSON source and fill/outline layers; migrate communities page to MapLibre with FeatureCollection, popups, Svelte Socials mounting, sorted rendering, fit-to-selection, and hash sync.
Add Location & Merchant pages
src/routes/add-location/+page.svelte, src/routes/merchant/[id]/+page.svelte
Replace inline Leaflet maps with MapLibre, add Navigation/Geolocate controls, MapLibre Marker placement, sprite sync, theme-driven setStyle, and simplified teardown.
CSS & UI tweaks
src/app.css, src/routes/map/components/MapSearchBar.svelte
Relocate dark-mode select option rule and adjust inactive tab hover color for dark mode.
Tests & helpers
tests/*, tests/helpers.ts
Update Playwright tests to use MapLibre selectors/canvas, change marker readiness polling to window.__mapPlacesCount, add communities tests, and prefer URL-driven drawer state in tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

Review effort 4/5

Suggested reviewers

  • dadofsambonzuki
  • bubelov

"🐰 I hopped through code and seeds of change,
Map tiles now shimmer under WebGL's range,
Icons baked crisp, controls neatly spun,
From Leaflet to MapLibre — the migration's done!"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(map): switch to maplibre native' clearly and concisely summarizes the main change—migrating the mapping library from Leaflet to MapLibre, which is the primary focus of this substantial PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Description check ✅ Passed The PR description is comprehensive, addressing the main migration from Leaflet to MapLibre with detailed feature improvements and user-facing enhancements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feat/maplibre-native

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 and usage tips.

This comment was marked as resolved.

escapedcat added a commit that referenced this pull request May 13, 2026
Six issues flagged in the Copilot code review, all fixed:

1. AreaMapNext: drop the `geoJSON as GeoJSON.GeoJSON` cast.
   `GeoJSON` is a type alias from the geojson package, not a namespace,
   so the namespaced reference doesn't compile under strict TS. The
   prop is already typed as GeoJSON, no cast needed.

2. map-next hover hull: clear cluster-hull source when convex returns
   null. Previously a degenerate cluster (≤ 2 unique points / collinear)
   would leave the previous hover's hull on the map until mouseleave.

3. map-next sprite loading: isolate the saved-badge fetch with a catch.
   The pin and cluster-hit sprites are local; the saved-badge sprite
   fetches the bookmark glyph from iconify.design. A transient Iconify
   outage was previously aborting the entire Promise.all and leaving
   the map in a half-initialized state with no source/layers.

4-6. Destroyed guards after `await import("maplibre-gl")`:
   - map-next/+page.svelte
   - components/area/AreaMapNext.svelte
   - components/MultiPlaceMapNext.svelte
   All three set a `destroyed` flag in onDestroy and bail right after
   the dynamic import resolves. Prevents the race where fast navigation
   away leaves a Map instance attached to a stale container with no
   cleanup path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🧹 Nitpick comments (9)
src/components/area/AreaMapNext.svelte (2)

80-84: 💤 Low value

tippy() may leak instances on re-runs of the reactive block.

The reactive statement re-evaluates whenever gradeTooltip (or any tracked dep) changes, but tippy([gradeTooltip], {...}) creates a fresh instance without destroying the prior one. In practice gradeTooltip is a stable bind:this target so this rarely re-runs, but it's worth guarding:

♻️ Proposed guarded init
-$: gradeTooltip &&
-	tippy([gradeTooltip], {
-		content: GradeTable,
-		allowHTML: true,
-	});
+let tippyInstance: ReturnType<typeof tippy>[number] | undefined;
+$: if (gradeTooltip && !tippyInstance) {
+	[tippyInstance] = tippy([gradeTooltip], {
+		content: GradeTable,
+		allowHTML: true,
+	});
+}
+onDestroy(() => tippyInstance?.destroy());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/area/AreaMapNext.svelte` around lines 80 - 84, The reactive
block that calls tippy([gradeTooltip], { content: GradeTable, allowHTML: true })
can leak tooltip instances on re-runs; modify AreaMapNext.svelte to store the
created instance (e.g., let gradeTippy) and before creating a new one check if
gradeTippy exists and call gradeTippy.destroy(), then assign the new tippy
instance to gradeTippy, and also destroy gradeTippy in onDestroy — keep the same
content: GradeTable and allowHTML settings and reference the gradeTooltip
bind:this target.

350-369: ⚡ Quick win

Font weight inconsistency between styles: ensure Noto Sans Bold is available in both light and dark map styles.

The component uses Noto Sans Bold (line 358), while src/routes/map-next/+page.svelte uses Open Sans Semibold. Both AreaMapNext styles reference the same openfreemap glyphs endpoint (https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf). The Liberty style does use Noto Sans Bold in its layers, confirming availability; however, the dark style only references Noto Sans Regular, suggesting potential weight inconsistency. Standardize font weights across both styles or verify the dark style actually supports the Bold weight, even if it doesn't use it in base layers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/area/AreaMapNext.svelte` around lines 350 - 369, The layer
"comment-badge-count" in AreaMapNext.svelte uses "Noto Sans Bold" while the dark
map style only supplies "Noto Sans Regular" (and +page.svelte uses "Open Sans
Semibold"), causing weight inconsistency; update the dark style to include the
Bold weight at the glyphs source or change the layer's "text-font" to a weight
that exists in both styles (e.g., switch "Noto Sans Bold" to a mutually
available font like "Open Sans Semibold" or add "Noto Sans Bold" into the dark
style's fontstack/glyphs config pointing to
https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf), then verify the
"comment-badge-count" layer and other layers render consistently in both light
and dark styles.
src/components/MultiPlaceMapNext.svelte (3)

241-242: 💤 Low value

stopPropagation() on originalEvent does not prevent the bare-map click handler.

e.originalEvent?.stopPropagation?.() only stops DOM propagation; MapLibre dispatches the unscoped click listener at line 257 regardless. The bare-map handler is already guarded by queryRenderedFeatures(...) (line 259), so the stopPropagation line is effectively dead code and can be removed to avoid implying a guarantee it doesn't provide.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/MultiPlaceMapNext.svelte` around lines 241 - 242, Remove the
redundant e.originalEvent?.stopPropagation?.(); call in the click handler in
MultiPlaceMapNext.svelte — MapLibre still fires the unscoped `click` listener in
the bare-map handler, and that handler already guards with
`queryRenderedFeatures(...)`, so delete the stopPropagation invocation to avoid
implying it prevents the bare-map click; keep the existing
`queryRenderedFeatures` guard intact and ensure no other logic expects event
propagation to be blocked.

345-359: 💤 Low value

Rapid theme toggles can leave overlapping style.load handlers in flight.

applyTheme early-returns when !styleLoaded, but if the user toggles theme during the brief window between setStyle and the next style.load, the second invocation is silently dropped — the map can end up settled on the wrong style. Two options:

  1. Track a pendingTheme and re-apply once styleLoaded flips back to true.
  2. Compare $theme against lastAppliedTheme after each style.load and re-run applyTheme if it diverged.

In practice the window is small (~tile compile latency), so this is low priority, but worth a note for the polish pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/MultiPlaceMapNext.svelte` around lines 345 - 359, applyTheme
can drop theme changes when styleLoaded is false because it early-returns,
leaving switched themes ignored; change it to record the desired theme (e.g.,
pendingTheme) before bailing and, in the onStyleLoad handler (registered via
map.once("style.load")), compare the current theme/ pendingTheme against
lastAppliedTheme and call applyTheme again if they differ (or directly apply the
pendingTheme), ensuring map.setStyle and initializeMapContents are retried;
alternatively implement the suggested post-load check in onStyleLoad to re-run
applyTheme when the active theme diverged from lastAppliedTheme.

89-96: ⚡ Quick win

External Iconify fetch lacks timeout and runs on every new icon on the render path.

fetch(url) here has no AbortSignal.timeout(...), no retry, and no offline fallback — a stalled api.iconify.design response will leave the pin missing indefinitely (the styleimagemissing stub stays in place forever). For a third-party CDN dependency on a map's primary visual element, consider:

  • Adding AbortSignal.timeout(5000) (or similar) to the fetch call.
  • Logging the failure path so silent spritePromises.delete(key) in ensureSprite is observable.
  • Longer term, bundling the small set of category SVGs locally — the dataset has a known, bounded icon vocabulary.

The MultiPlaceMap use case is for saved places that all share currency_bitcoin, so it's especially wasteful to depend on a network round trip for a single statically-known glyph here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/MultiPlaceMapNext.svelte` around lines 89 - 96, The
fetchIconInnerSvg function currently calls fetch(url) without timeout, retry,
fallback, or log, causing missing pins if api.iconify.design stalls; update
fetchIconInnerSvg to use AbortSignal.timeout(5000) (or configurable timeout)
when calling fetch, add a simple retry (1-2 attempts) and catch to return a
local bundled fallback SVG for known category icons (e.g., currency_bitcoin)
instead of leaving styleimagemissing, and log failures (include the URL, status
or error) before any cleanup like the spritePromises.delete(key) in ensureSprite
so failures are observable; keep the icon path resolution via resolveIconifyName
and return the fallback text when network fetch ultimately fails.
src/routes/map-next/+page.svelte (4)

1056-1056: 💤 Low value

Verify MerchantDrawerHash import path from a sibling route.

../map/components/MerchantDrawerHash.svelte resolves into another route directory. SvelteKit treats only +page.* / +layout.* / +server.* / +error.* as route files, so non-prefixed .svelte files in src/routes/map/components/ are technically just colocated modules and shouldn't conflict — but using a sibling route's private components from another route is a smell that creates an implicit cross-route coupling. If this is intentional during the incremental migration, consider hoisting MerchantDrawerHash to src/components/map/ so neither route owns it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/map-next/`+page.svelte at line 1056, The current use of
MerchantDrawerHash in +page.svelte imports from a sibling route's module
(../map/components/MerchantDrawerHash.svelte), creating an implicit cross-route
coupling; to fix, either move/duplicate MerchantDrawerHash into a shared
location (e.g., src/components/map/) and update the import in
src/routes/map-next/+page.svelte to the new shared path, or explicitly export a
stable shared module from a non-route directory and update all consumers to
import that shared symbol (MerchantDrawerHash) instead of importing from another
route's components folder.

264-265: 💤 Low value

Hardcoded LINK_COLOR = "#0099AF" duplicates the Tailwind palette value.

Per the inline comment ("Tailwind text-link color"), this constant must stay in sync with tailwind.config.js. Two mitigations:

  • If using Tailwind v4 (CSS-first config), export the color via a CSS custom property (--color-link) and read it via getComputedStyle(document.documentElement).getPropertyValue('--color-link') once at sprite-build time.
  • Or extract the palette to a small TS module imported by both the config and the component.

Minor — current code works, but drift here means a saved-badge ring that no longer matches the rest of the UI.

Also applies to: 302-315

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/map-next/`+page.svelte around lines 264 - 265, The saved-badge SVG
uses a hardcoded LINK_COLOR string which can drift from Tailwind's palette;
update buildSavedBadgeSvg (and related usage around lines ~302-315) to obtain
the link color dynamically instead of the literal "#0099AF": either read a CSS
custom property (e.g., define --color-link in tailwind.config.css and call
getComputedStyle(document.documentElement).getPropertyValue('--color-link') once
during sprite/build time and pass that value into buildSavedBadgeSvg) or extract
the palette into a small shared TS module that both tailwind.config.js and this
component import so buildSavedBadgeSvg uses the shared constant; ensure the
chosen approach returns a valid color string and replace the LINK_COLOR constant
with that dynamic value.

159-180: 💤 Low value

Hash writer drops bearing when only pitch is non-zero.

Read the format carefully: if bearing === 0 and pitch !== 0, then:

  • Line 170 evaluates c.bearing !== 0 || c.pitch !== 0 → true → appends /0.0 for bearing.
  • Line 173 then appends /${pitch}.

So the encoded hash becomes …/lng/0.0/pitch — readable, but on the next parse parts[3] = "0.0" (bearing) and parts[4] is pitch, which round-trips correctly. This is OK. Verified the round-trip is intact.

However, a subtle issue: history.replaceState fires on every moveend (line 1013) including programmatic moves triggered by setView, flyTo, geolocate-tracking updates, and even the initial fitBounds. Firefox/Safari throttle replaceState to ~100/30s; rapid pan+rotate gestures can hit that limit and start dropping updates. Consider debouncing persistViewportToHash to ~250ms like the enrichment trigger.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/map-next/`+page.svelte around lines 159 - 180, The map's
writeHashCoords/persistViewportToHash path is called on every moveend (including
programmatic setView/flyTo/geolocate updates), causing frequent
history.replaceState calls that can be throttled by browsers; debounce
persistViewportToHash (e.g., 250ms) so rapid consecutive moveend events collapse
into one replaceState. Implement a single debounced wrapper (using a simple
setTimeout/clearTimeout or a debounce util) and use that wrapper wherever
persistViewportToHash is currently invoked (reference persistViewportToHash,
writeHashCoords, and the map 'moveend' listener), ensuring programmatic callers
still call the debounced function rather than calling history.replaceState
directly.

467-471: 💤 Low value

Geolocate listener and click handlers are not torn down on destroy.

map?.remove() will remove the GL context and stop rendering, and per MapLibre's Map#remove it does dispose of _listeners — so this is fine for handlers registered via map.on(...). However:

  • The geolocate.on("geolocate", …) callback (line 593) captures userLocation.setLocation and the geolocate control's lifecycle is tied to the map; remove cleans it.
  • triggerEnrichmentIfNeeded.cancel() correctly cancels the in-flight debounce. Good.

What's missing: if the cluster-hover async getClusterLeaves callback (line 962) is in flight at destroy time, it resolves after map = undefined and tries map.getSource("cluster-hull") indirectly via the resolved hullSource reference. The reference is captured before the await, so it stays valid — but hullSource.setData on a destroyed map may throw or warn. Consider:

 source.getClusterLeaves(clusterId, limit, 0).then((leaves) => {
+	if (destroyed) return;
 	if (latestHullClusterId !== clusterId) return;

Also applies to: 1029-1035

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/map-next/`+page.svelte around lines 467 - 471, The geolocate
handler and async cluster-leaves callbacks can still run after the map is
destroyed; keep a reference to the geolocate listener function and unregister it
(call geolocate.off("geolocate", handler) or remove the control) during
teardown, and also guard or cancel any in-flight cluster fetches so they don't
call hullSource.setData on a destroyed map: for the cluster-hover async
getClusterLeaves callback, use an AbortController or check the same destroyed
flag (or map truthiness) right before calling hullSource.setData (and skip if
destroyed), and ensure any click handlers registered via map.on(...) are removed
with map.off(...) in the destroy path; refer to the geolocate.on("geolocate",
...) handler, the getClusterLeaves async callback that calls hullSource.setData,
and triggerEnrichmentIfNeeded.cancel() when implementing these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/area/AreaMapNext.svelte`:
- Around line 510-518: The grade currently always computes to 100% because both
total and upToDate are set to filteredPlaces.length; change the logic so total
reflects the full set of places (pre-verification) and upToDate reflects the
verified/filtered count: leave upToDate = filteredPlaces.length but set total to
the unfiltered places collection (e.g. the variable that contains allPlaces or
places before AreaPage filtering), compute upToDatePercent using (upToDate /
total) * 100 with a guard for total === 0, and then call grade =
getGrade(Number(upToDatePercent.toFixed(0))) so getGrade receives the correct
percentage.

In `@src/components/MultiPlaceMapNext.svelte`:
- Around line 59-128: This file duplicates the pin-sprite pipeline
(materialExceptions, resolveIconifyName, PIN_PATH, PIN_FILL_REGULAR, spriteName,
fetchIconInnerSvg, buildCompositeSvg, loadSvgImage, ensureSprite and
transparentPixel) that exists in AreaMapNext.svelte and map-next/+page.svelte;
extract these helpers into a new module (e.g. $lib/maplibre/pinSprites.ts) and
export resolveIconifyName, spriteName(icon, boosted?), ensureSprite(map, icon,
boosted?), and installPlaceholderHandler(map). Replace the in-file definitions
in MultiPlaceMapNext.svelte with imports of those functions and update local
calls (resolveIconifyName, spriteName, ensureSprite, buildCompositeSvg,
fetchIconInnerSvg, loadSvgImage) to use the exported APIs, ensuring the map-next
variant’s fallback behavior and AreaMapNext’s boosted variant are supported by
adding optional boosted and fallback parameters in the new module so callers can
pass through their existing semantics.

In `@src/routes/map-next/`+page.svelte:
- Around line 721-727: The MapLibre style expression assumes boosted is non-null
and will throw; update the expression used in the icon-image concat to coalesce
boosted to a boolean (mirror the AreaMapNext.svelte pattern) by replacing
["case", ["get", "boosted"], "b", "r"] with ["case", ["coalesce", ["get",
"boosted"], false], "b", "r"]; apply the identical defensive change wherever the
same expression is used (notably in the spiderLeavesLayout-generated leaf
features) and keep buildFeatureCollection untouched but consistent with this
coalesce pattern.
- Around line 482-548: The style currently points glyphs to
"https://demotiles.maplibre.org/..." and uses raw OpenStreetMap tiles in the
"osm" source (and public CARTO tiles in "carto-light"/"carto-dark"), which are
demo/free-tier endpoints unsuitable for production; update the glyphs property
to point to your self-hosted or paid glyph service (e.g., PMTiles/protomaps on
static.btcmap.org or a vendor), replace the "osm" source tiles array with your
self-hosted tile endpoint or a paid tile vendor (or Stadia/MapTiler/Protomaps)
and confirm "carto-light"/"carto-dark" usage complies with CARTO's terms or swap
them to a supported provider, keeping the existing layer ids ("osm",
"carto-light", "carto-dark") and layout visibility logic intact (no changes to
initialBasemap checks required).

---

Nitpick comments:
In `@src/components/area/AreaMapNext.svelte`:
- Around line 80-84: The reactive block that calls tippy([gradeTooltip], {
content: GradeTable, allowHTML: true }) can leak tooltip instances on re-runs;
modify AreaMapNext.svelte to store the created instance (e.g., let gradeTippy)
and before creating a new one check if gradeTippy exists and call
gradeTippy.destroy(), then assign the new tippy instance to gradeTippy, and also
destroy gradeTippy in onDestroy — keep the same content: GradeTable and
allowHTML settings and reference the gradeTooltip bind:this target.
- Around line 350-369: The layer "comment-badge-count" in AreaMapNext.svelte
uses "Noto Sans Bold" while the dark map style only supplies "Noto Sans Regular"
(and +page.svelte uses "Open Sans Semibold"), causing weight inconsistency;
update the dark style to include the Bold weight at the glyphs source or change
the layer's "text-font" to a weight that exists in both styles (e.g., switch
"Noto Sans Bold" to a mutually available font like "Open Sans Semibold" or add
"Noto Sans Bold" into the dark style's fontstack/glyphs config pointing to
https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf), then verify the
"comment-badge-count" layer and other layers render consistently in both light
and dark styles.

In `@src/components/MultiPlaceMapNext.svelte`:
- Around line 241-242: Remove the redundant
e.originalEvent?.stopPropagation?.(); call in the click handler in
MultiPlaceMapNext.svelte — MapLibre still fires the unscoped `click` listener in
the bare-map handler, and that handler already guards with
`queryRenderedFeatures(...)`, so delete the stopPropagation invocation to avoid
implying it prevents the bare-map click; keep the existing
`queryRenderedFeatures` guard intact and ensure no other logic expects event
propagation to be blocked.
- Around line 345-359: applyTheme can drop theme changes when styleLoaded is
false because it early-returns, leaving switched themes ignored; change it to
record the desired theme (e.g., pendingTheme) before bailing and, in the
onStyleLoad handler (registered via map.once("style.load")), compare the current
theme/ pendingTheme against lastAppliedTheme and call applyTheme again if they
differ (or directly apply the pendingTheme), ensuring map.setStyle and
initializeMapContents are retried; alternatively implement the suggested
post-load check in onStyleLoad to re-run applyTheme when the active theme
diverged from lastAppliedTheme.
- Around line 89-96: The fetchIconInnerSvg function currently calls fetch(url)
without timeout, retry, fallback, or log, causing missing pins if
api.iconify.design stalls; update fetchIconInnerSvg to use
AbortSignal.timeout(5000) (or configurable timeout) when calling fetch, add a
simple retry (1-2 attempts) and catch to return a local bundled fallback SVG for
known category icons (e.g., currency_bitcoin) instead of leaving
styleimagemissing, and log failures (include the URL, status or error) before
any cleanup like the spritePromises.delete(key) in ensureSprite so failures are
observable; keep the icon path resolution via resolveIconifyName and return the
fallback text when network fetch ultimately fails.

In `@src/routes/map-next/`+page.svelte:
- Line 1056: The current use of MerchantDrawerHash in +page.svelte imports from
a sibling route's module (../map/components/MerchantDrawerHash.svelte), creating
an implicit cross-route coupling; to fix, either move/duplicate
MerchantDrawerHash into a shared location (e.g., src/components/map/) and update
the import in src/routes/map-next/+page.svelte to the new shared path, or
explicitly export a stable shared module from a non-route directory and update
all consumers to import that shared symbol (MerchantDrawerHash) instead of
importing from another route's components folder.
- Around line 264-265: The saved-badge SVG uses a hardcoded LINK_COLOR string
which can drift from Tailwind's palette; update buildSavedBadgeSvg (and related
usage around lines ~302-315) to obtain the link color dynamically instead of the
literal "#0099AF": either read a CSS custom property (e.g., define --color-link
in tailwind.config.css and call
getComputedStyle(document.documentElement).getPropertyValue('--color-link') once
during sprite/build time and pass that value into buildSavedBadgeSvg) or extract
the palette into a small shared TS module that both tailwind.config.js and this
component import so buildSavedBadgeSvg uses the shared constant; ensure the
chosen approach returns a valid color string and replace the LINK_COLOR constant
with that dynamic value.
- Around line 159-180: The map's writeHashCoords/persistViewportToHash path is
called on every moveend (including programmatic setView/flyTo/geolocate
updates), causing frequent history.replaceState calls that can be throttled by
browsers; debounce persistViewportToHash (e.g., 250ms) so rapid consecutive
moveend events collapse into one replaceState. Implement a single debounced
wrapper (using a simple setTimeout/clearTimeout or a debounce util) and use that
wrapper wherever persistViewportToHash is currently invoked (reference
persistViewportToHash, writeHashCoords, and the map 'moveend' listener),
ensuring programmatic callers still call the debounced function rather than
calling history.replaceState directly.
- Around line 467-471: The geolocate handler and async cluster-leaves callbacks
can still run after the map is destroyed; keep a reference to the geolocate
listener function and unregister it (call geolocate.off("geolocate", handler) or
remove the control) during teardown, and also guard or cancel any in-flight
cluster fetches so they don't call hullSource.setData on a destroyed map: for
the cluster-hover async getClusterLeaves callback, use an AbortController or
check the same destroyed flag (or map truthiness) right before calling
hullSource.setData (and skip if destroyed), and ensure any click handlers
registered via map.on(...) are removed with map.off(...) in the destroy path;
refer to the geolocate.on("geolocate", ...) handler, the getClusterLeaves async
callback that calls hullSource.setData, and triggerEnrichmentIfNeeded.cancel()
when implementing these changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db99e84b-a5a0-4a24-b3b3-2a91d08fe80d

📥 Commits

Reviewing files that changed from the base of the PR and between 312eb32 and 59cf5f2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • package.json
  • src/components/MultiPlaceMapNext.svelte
  • src/components/area/AreaMapNext.svelte
  • src/routes/+layout.svelte
  • src/routes/map-next/+page.svelte

Comment thread src/components/area/AreaMapNext.svelte Outdated
Comment thread src/components/MultiPlaceMapNext.svelte Outdated
Comment thread src/routes/map-next/+page.svelte Outdated
Comment thread src/routes/map-next/+page.svelte Outdated
escapedcat added a commit that referenced this pull request May 14, 2026
Six issues flagged in the Copilot code review, all fixed:

1. AreaMapNext: drop the `geoJSON as GeoJSON.GeoJSON` cast.
   `GeoJSON` is a type alias from the geojson package, not a namespace,
   so the namespaced reference doesn't compile under strict TS. The
   prop is already typed as GeoJSON, no cast needed.

2. map-next hover hull: clear cluster-hull source when convex returns
   null. Previously a degenerate cluster (≤ 2 unique points / collinear)
   would leave the previous hover's hull on the map until mouseleave.

3. map-next sprite loading: isolate the saved-badge fetch with a catch.
   The pin and cluster-hit sprites are local; the saved-badge sprite
   fetches the bookmark glyph from iconify.design. A transient Iconify
   outage was previously aborting the entire Promise.all and leaving
   the map in a half-initialized state with no source/layers.

4-6. Destroyed guards after `await import("maplibre-gl")`:
   - map-next/+page.svelte
   - components/area/AreaMapNext.svelte
   - components/MultiPlaceMapNext.svelte
   All three set a `destroyed` flag in onDestroy and bail right after
   the dynamic import resolves. Prevents the race where fast navigation
   away leaves a Map instance attached to a stale container with no
   cleanup path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@escapedcat escapedcat force-pushed the feat/maplibre-native branch from ebae51c to cc2cea9 Compare May 14, 2026 07:56
@socket-security

socket-security Bot commented May 14, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​nazka/​map-gl-js-spiderfy@​2.0.0701009791100
Addedmaplibre-gl@​5.24.0981001009970
Added@​turf/​convex@​7.3.51001009290100
Added@​turf/​helpers@​7.3.510010010090100

View full report

@dadofsambonzuki

Copy link
Copy Markdown
Member

Looks and feels great. Rotate and pan on mobile work well. I also like the more granular zoom levels all round.

And the merchant deep links on the map are super cool too!

Feedback:

  • Rotation gestures seem weird on desktop. Shift-click-mousemove has the map rotating the opposite way I would expect. Clicking-hold-mousemove on the compass icon is a ride!
  • Feels like place name labels should appear at a higher zoom. Maybe 10? I guess clustering would overide this.
  • Basemap switcher has dark background in dark mode.

@escapedcat

Copy link
Copy Markdown
Contributor Author

Looks and feels great. Rotate and pan on mobile work well. I also like the more granular zoom levels all round.

🙌

And the merchant deep links on the map are super cool too!

Which ones? I assume everything is like before? :D

* Rotation gestures seem weird on desktop. Shift-click-mousemove has the map rotating the opposite way I would expect. Clicking-hold-mousemove on the compass icon is a ride!

Will check, I assume currently everything as it comes default OOTB

* Feels like place name labels should appear at a higher zoom. Maybe 10? I guess clustering would overide this.

Should be same as it is on prod currently. But can be adjusted.

* Basemap switcher has dark background in dark mode.

You mean that select or that light/dark-theme switcher?

Sound like this is mainly fine. Will add missing changes step by step via follow up PRs into this one.

@dadofsambonzuki

Copy link
Copy Markdown
Member

Maybe rotations on desktop is user error. If you use mouse left/right, rather than mouse up/down it's fine.

@dadofsambonzuki

This comment was marked as outdated.

@dadofsambonzuki dadofsambonzuki changed the title feat(map): switch to maplibre native for rotation feat(map): switch to maplibre native May 18, 2026
escapedcat and others added 15 commits May 24, 2026 16:00
Phase 0 of the Leaflet → MapLibre native migration. Adds an isolated
/map-next route alongside the existing /map, rendering an OSM raster
basemap with NavigationControl (zoom + compass) and native two-finger
rotation enabled. No markers, no clustering — just the foundation.

Verified under rotation: tiles fill the viewport correctly at any
bearing, which was the blocking Risk F that killed the leaflet-rotate
spike. Vector basemaps come in Phase 4.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subscribe to $places store, convert to GeoJSON, and render through a
clustered MapLibre source with three layers (outer disc, inner disc,
count symbol) matching leaflet.markercluster default styling, plus a
placeholder circle layer for unclustered points. Sprites land in Phase 2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small fixes needed to make Phase 1 visible end-to-end:

1. Add `/map-next` to the fullscreen route allowlist in +layout.svelte
   so the site header/nav doesn't render on top of the map.

2. Add a `glyphs:` URL to the inline style spec and a `text-font` to
   the cluster-count symbol layer. MapLibre requires both for any
   symbol layer with `text-field`. Using the official MapLibre demo
   glyph server for now; Phase 4 swaps to vector basemaps that ship
   their own glyphs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First cut of Phase 2. Replaces the placeholder orange dots with the
existing /icons/div-icon-pin.svg (teal) and /icons/boosted-icon-pin.svg
(orange) loaded via map.addImage and rendered through a symbol layer.

Boost state is derived from boosted_until via the existing isBoosted
utility, then projected into the feature property `boosted` so the
symbol layer can pick the right sprite via a 'case' expression.

Pins are anchored at bottom (so the point tip sits on the coordinate)
and pinned to the viewport for rotation/pitch — they stay upright as
the map turns.

Category icons inside the pin, comment-count badges, saved-state
overlay, and boost-glow animation are still to come within Phase 2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generate composite pin sprites at runtime — each unique (icon, boosted) pair
fetches its Material icon from the Iconify static API, composes it inside
the 32x43 pin shape, and registers it via map.addImage. The unclustered-point
symbol layer now resolves icon-image dynamically by concatenating the place's
icon and boosted flag. Sprite generation is deduped via a module-scoped
promise cache and triggered on every places sync; failed fetches are evicted
so they can retry on the next tick.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two symbol layers above the composite pin:
- `comment-badge`: green disc with count at pin top-right, using
  text-halo as the disc background so 1- to 3-digit counts stay
  legible without an extra sprite.
- `saved-badge`: white disc with bookmark glyph at pin top-left,
  pre-rendered into a 16x16 sprite at map load.

Extends GeoJSON feature properties with `comments` and `saved`,
and adds a `$savedPlaceIds`-size trigger so the source rebuilds
when the user toggles saves.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use supercluster's getClusterExpansionZoom to ease the camera into a
cluster on click. Pointer cursor on cluster and pin hover gives the
usual click affordance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click an unclustered pin → call merchantDrawer.open(id, "details"). The
drawer is already mounted at the layout level and reacts to store state,
so no extra wiring is needed here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add @nazka/map-gl-js-spiderfy and apply it to an invisible "clusters-hit"
symbol layer. The library auto-routes click events: if a cluster can
still split via getClusterExpansionZoom, it eases the camera; if not
(e.g. multiple places at the same coords at max cluster zoom), it
spiderfies the leaves into a circle/spiral. Leaves use our pin-sprite
icon-image expression, and onLeafClick forwards to merchantDrawer.open.

Also pre-install @turf/convex and @turf/helpers — used in the next
commit for the cluster-hover convex hull.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On mouseenter of a cluster, fetch its leaves (capped at 500), build a
@turf/convex polygon and push it into a dedicated cluster-hull GeoJSON
source. The fill + outline layers sit below the cluster discs so the
soft green tint reads as a footprint, not an overlay. mouseleave clears
the source. A latestHullClusterId guard discards stale leaf callbacks
when the cursor sweeps between clusters faster than getClusterLeaves
resolves.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…awer

The Phase 3 click handler calls merchantDrawer.open() but the actual
drawer component lives in /map and is only mounted there. Without
mounting it on /map-next, the store updates land in the void and no UI
appears. Reuse the existing MerchantDrawerHash component from
../map/components/ — both routes share state via the global
merchantDrawer store anyway, and the component handles desktop/mobile
switching internally.

This stays in /map/components/ for now rather than being moved to a
shared location, because both routes coexist until the Phase 7
cutover; moving it would force a refactor of the /map page mid-
migration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ad of always spidering

The @nazka spiderfy library's internal decision logic is:
  expansionZoom > forceSpiderifyMinZoom || expansionZoom > maxZoom
    → spiderfy
  else
    → easeTo(expansionZoom)

Default forceSpiderifyMinZoom is null. In JavaScript, `r > null`
evaluates as `r > 0`, which is always true for any cluster's
expansion zoom — so the default behavior is to spider every cluster
click, regardless of zoom level. That's why a normal cluster at zoom
3 was spidering out 150+ pins instead of zooming in.

Setting forceSpiderifyMinZoom to CLUSTERING_DISABLED_ZOOM (17) means:
- expansionZoom ≤ 17 → easeTo (normal cluster zoom-in)
- expansionZoom > 17 → spiderfy (only for coincident points that
  can't be broken apart by zooming, e.g. multiple places at the same
  lat/lon)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…halo

Previously the comment count badge used text-halo-width as the disc
background. That looked off at single-digit counts because the halo
hugs the glyph, so "1" rendered noticeably smaller than prod's flat
16px disc.

Switch to two stacked symbol layers sharing one offset:
- comment-badge (icon symbol, comment-badge-bg sprite)
- comment-badge-count (text symbol, count rendered on top)

The disc sprite is now drawn directly on a canvas (no SVG roundtrip)
since it's just a flat circle.

Text bumped 10 → 11 px for legibility. Open Sans Semibold is the
weight; demotiles glyphs don't ship a Bold variant, so Phase 4's
vector basemap can later bump weight if needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port `/map`'s viewport-bound enrichment pattern to the MapLibre-native
route: at zoom >= LABEL_VISIBLE_ZOOM, a debounced moveend handler calls
merchantList.fetchEnrichedDetails(), and a new `place-label` symbol
layer reads each feature's resolved name (enriched-cache > $places.name
> osm:amenity) from the GeoJSON properties. Labels appear progressively
as the cache fills.

The reactive rebuild block now also triggers on placeDetailsCache size
changes (same size-tracker pattern as $places/savedPlaceIds; same known
swap-edge limitation). Styling mirrors prod's `.marker-label` /
`.marker-label-boosted` (cyan-700 / orange-500, white halo). Dark mode
and localized_name are deferred to Phase 5/6.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Default to OpenFreeMap Liberty (light) / static.btcmap.org dark style
  (dark) so the page no longer ships with the inline OSM raster style.
- Add a typed BASEMAPS catalog (5 entries: 2× OpenFreeMap, 2× Carto,
  OSM raster fallback) and a top-right <select> switcher; the user's
  pick persists via localStorage(btcmap-basemap).
- Refactor the source/layer setup into addPlacesSourceAndLayers and
  the sprite registration into loadAllSprites so they can re-run on
  every style.load (setStyle wipes sources, layers, and the image
  registry). Spiderfy / click / hover handlers stay set up once on
  the initial map.load.
- Clear the composite-pin spritePromises cache on style.load so
  ensureSpritesForPlaces rebuilds the per-icon sprites against the
  fresh image registry.
- Widen text-font on cluster-count, comment-badge-count, and
  place-label to ["Open Sans Semibold", "Noto Sans Regular"] — the
  new vector basemaps may not ship Open Sans, so we fall back to the
  Noto stack they all include.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
escapedcat and others added 3 commits May 26, 2026 10:13
Pin sprites were rasterized at 2× the declared 32×43 px and registered
with pixelRatio: 2. Sharp on 2× retina monitors, but most modern phones
report DPR 3 (or 4), so MapLibre upscaled the 64×86 bitmap ~50% for
native rendering — visibly soft category glyphs.

Bump PIN_RENDER_SCALE from 2 → 3. Sprite cache doubles in memory but
the category-icon set in view at any time is bounded (low dozens), so
the impact is tens of KB at most. The composite SVG and inner Iconify
fetch are unchanged; only the rasterization resolution + pixelRatio
flag at addImage time shift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The green disc behind each pin's comment-count number was drawn into a
16×16 canvas and registered with pixelRatio: 1. On retina displays
MapLibre upscaled the 16×16 bitmap to 32×32 native pixels — soft
edges, visible "bitmap blur" on the disc.

Bump the canvas to 32×32 (2× SIZE) and register with pixelRatio: 2.
Display size unchanged at 16 logical px; pixel data now matches retina
1:1. Same change applied to /merchant/[id] and AreaMap (each has its
own copy of loadCommentBadgeSprite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The white-disc + bookmark badge on saved places was assembled as a 16×16
SVG composite and registered with pixelRatio: 1. Same blur on retina as
the comment badge had.

Bump the composite SVG's width/height to 2× (viewBox unchanged) and
register with pixelRatio: 2. The inner Iconify bookmark glyph
re-rasterizes at the new resolution because nested SVG content scales
with the outer's bitmap size. Display dimensions unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dadofsambonzuki

Copy link
Copy Markdown
Member

Sooooo much better!

Only nit is the close button on the community map looks a bit off.

Screenshot From 2026-05-27 11-04-15

@dadofsambonzuki

dadofsambonzuki commented May 27, 2026

Copy link
Copy Markdown
Member

A more general thoughts was around any impact to apps that have integrated via WebView/iFrame.

Here's what GPT5.5 said:

Main risks I found:

  1. WebGL requirement
    MapLibre GL JS requires WebGL. Leaflet did not. Most modern Android WebView and iOS WKWebView support it, but older devices, disabled hardware acceleration, restricted WebViews, or privacy-hardened browsers can fail or show a blank map.
  2. URL compatibility
    Existing /map supports initial position via ?lat=...&long=..., including two pairs for bounds: src/routes/map/+page.svelte:323-325, 1242-1253.
    The MapLibre implementation in /map-next currently restores #zoom/lat/lng hashes, but I did not see equivalent ?lat=&long= support. If embedded apps use query params for initial viewport, that would break unless preserved.
  3. Hash compatibility mostly preserved
    Existing Leaflet URLs use #zoom/lat/lon&merchant=123.
    MapLibre’s mapHash.ts intentionally mirrors that and preserves merchant / view params, with optional extra /bearing/pitch fields. Old links should parse, and new links should remain mostly backward-compatible.
  4. No native bridge impact
    I found no postMessage, window.ReactNativeWebView, Flutter bridge, or parent iframe messaging API in the map code. So apps should not be affected through a formal JS bridge because there does not appear to be one.
  5. DOM/CSS scraping will break
    Any integration or test that reaches into the map DOM using Leaflet-specific selectors like .leaflet-marker-pane, .leaflet-marker-cluster, or window.L will break. MapLibre uses canvas/WebGL plus .maplibregl-* controls instead.
  6. Zoom behavior may differ
    Current /map allows maxZoom: 21; /map-next uses maxZoom: 19. If embeds deep-link to very high zooms, behavior may change.
    My recommendation before replacing /map:
  7. Preserve ?lat=&long=, ?language=, ?boosts, #merchant=..., and #zoom/lat/lon behavior exactly.
  8. Add a WebGL unsupported fallback message instead of leaving embeds blank.
  9. Test in Android WebView, iOS WKWebView, Flutter webview_flutter, and React Native react-native-webview.
  10. Document that integrations should use URL parameters only, not Leaflet DOM/classes or window.L.
    Short answer: standard iframe/WebView embeds should be okay if URL compatibility is maintained, but WebGL support and missing ?lat=&long= handling are the two main compatibility risks.

My recommendation before replacing /map:

  1. Preserve ?lat=&long=, ?language=, ?boosts, #merchant=..., and #zoom/lat/lon behavior exactly.
  2. Add a WebGL unsupported fallback message instead of leaving embeds blank.
  3. Test in Android WebView, iOS WKWebView, Flutter webview_flutter, and React Native react-native-webview.
  4. Document that integrations should use URL parameters only, not Leaflet DOM/classes or window.L.
    Short answer: standard iframe/WebView embeds should be okay if URL compatibility is maintained, but WebGL support and missing ?lat=&long= handling are the two main compatibility risks.

What do you think?

We could 'test in live' for known integrations and quicky fallback if we see issues?

escapedcat and others added 23 commits May 27, 2026 13:45
Legacy /map read ?lat=X&long=Y for initial center and supported two pairs
as a fitBounds rectangle. Embeds linking with those params would land on
the default viewport after the MapLibre rewrite. Add a parseLatLongQuery
helper (with unit tests) and resolve the initial viewport in this order:
hash → ?lat&long → defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1003

Legacy /map used maxZoom: 21. The MapLibre rewrite tightened it to 19,
which silently clamped deep-linked zooms above that level. OpenFreeMap
Liberty serves vector tiles that overzoom cleanly to 21+, and Carto
raster tiles upscale at the worst case — same behavior the prod /map
already had.

Applied to /map, AreaMap, and MultiPlaceMap. Geolocate / fitBounds
caps (maxZoom: 15) intentionally left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1003

Leaflet rendered tiles to a DOM/canvas-2d pane that worked in any
modern browser. MapLibre requires WebGL, so older Android WebViews,
hardened browsers, restricted enterprise installs, and devices with
hardware acceleration disabled would leave the map container empty.

Add a small hasWebGL() helper that probes for webgl2/webgl/
experimental-webgl, and a shared MapUnsupportedFallback component that
fills the map's container slot with an explanatory message. Each of
the six MapLibre instantiation sites (/map, /merchant/[id],
/communities/map, /add-location, AreaMap, MultiPlaceMap) checks
hasWebGL() before instantiating and renders the fallback otherwise.

On /map also suppress the tile-loading chip when the fallback is up —
the indicator would otherwise sit stuck at "loading…" forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sites #1003

Follow-up to 28d47a0: the previous commit only updated /map, AreaMap,
and MultiPlaceMap. /merchant/[id], /add-location, and /communities/map
were still clamped to 19. Bumping them too so the entire app accepts
deep-linked street-level zooms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1003

Legacy /map set `noWrap: true` on the raster OSM layer so panning west
past the antimeridian didn't reveal a duplicate copy of the world.
MapLibre defaults to renderWorldCopies: true, so the rewrite was
showing repeated copies at low zoom. Match the legacy contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy /map subscribed to \$placesError and surfaced sync errors via
errToast so the user didn't sit on an empty map without explanation
(line 401 of the Leaflet version). The MapLibre rewrite dropped that
subscription entirely. Wire it back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy /map called setView(..., 17) for single search hits, ensuring
the pin landed at a zoom past CLUSTERING_DISABLED_ZOOM (17) so it
rendered as a standalone marker. The MapLibre rewrite used
DEFAULT_MAP_ZOOM (15), where the matched place can sit inside a
cluster disc — the user searches for a merchant and sees a cluster
instead of their pin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy /map registered a Leaflet L.control.scale via scaleBars(). The
MapLibre rewrite never replaced it, so users lost the visual distance
reference. ScaleControl is built into MapLibre — one addControl call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>


+page.server.ts already populated data.geo from Netlify's x-nf-geo
header, but +page.svelte never read it after the MapLibre rewrite, so
visitors with no URL state always started on Curaçao (the project
default). Legacy /map consulted data.geo as the next fallback after
hash/query — restore that behavior. Viewport resolution chain is now:
hash → ?lat&long → IP-geo → defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy /map injected a "Support BTC Map" anchor into Leaflet's
attribution control via DOM mutation (lib/map/setup.ts:support()), so
every map view exposed an entry point to the supporters page. The
MapLibre rewrite dropped this entirely. Inline the link into each
source's attribution string in the style spec — MapLibre's default
AttributionControl reads from there, so no post-init DOM tampering
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BasemapsControl.ts:139 references mapControls.basemapTitle for the
basemap-picker button tooltip, but the key didn't exist in any locale.
Non-EN users saw the literal key string. Add proper translations to
all 8 locales — keeps the i18n contract aligned per CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an embed links to /map with bad ?lat or ?long values (NaN,
out-of-range, asymmetric counts that wouldn't parse), the rewrite
silently falls through to defaults — no hint that the URL was the
problem. Legacy /map showed errors.mapView in this case. Match it by
detecting "has lat/long params but parseLatLongQuery returned null"
and toasting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
updateSinglePlace() in \$lib/sync/places.ts mutates a place in-place
and sets lastUpdatedPlaceId, but the source-sync guard in /map only
re-renders on length / size / locale changes — none of which fire for
an in-place mutation. The boosted-pin orange fill and the
comment-count badge wouldn't appear until the next full sync. Add a
parallel reactive that listens for lastUpdatedPlaceId and re-syncs the
source, then clears the store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy /map called clearNonSearchResultMarkers() + loadSearchResultMarkers()
when the user ran a search, so the map only showed the matched pins —
matching the panel list. The MapLibre rewrite filtered the list but
not the map. Users would search for a merchant and see thousands of
unrelated pins surrounding it.

Compute an "effective" pin list inside the existing source-sync
reactive: search mode with results → matched places; otherwise → all
\$places. Track a searchSig (joined result ids) alongside the existing
length / size trackers so a new search with the same hit count still
triggers a re-sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MapLibre rewrite dropped a handful of map controls and changed the
loading-status priority chain, leaving these keys unreferenced in all
8 locales:

mapControls.fullScreen, mapControls.fullScreenAlt — fullscreen
toggle removed from the control bar.
mapControls.support, mapControls.supportWithSats — legacy injected
a "Support BTC Map" link via DOM mutation; rewrite bakes the link
into the attribution string directly, no separate i18n entry needed.
errors.fullscreenError, errors.mapViewCachedCoords — orphaned with
the localforage coords cache and fullscreen toggle.
status.loadingPlaces, status.initializingMarkers,
status.loadingPlacesInView — superseded by the new priority chain
(syncing → preparing → tiles).

Verified each is unreferenced before deletion (errors.mapView kept —
it's used by /communities/map and by the new bad-coords toast).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#1003

Legacy /map tracked zoom_out_click, locate_click, and layer_change
events alongside the existing zoom_in_click. The MapLibre rewrite only
kept zoom_in_click, leaving a gap in map-UI telemetry.

MapLibre's built-in NavigationControl and GeolocateControl don't
expose JS-level click events, so attach DOM listeners to the buttons
in the container after addControl. BasemapsControl is our own IControl
— wire trackEvent directly inside #select.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy /map called clearNonMatchingMarkers(category) whenever the
user changed the panel's category filter, so the map and the list
stayed in sync. The MapLibre rewrite only filtered the list, leaving
every pin visible. Selecting "Restaurants" showed exactly the same
pin cloud as "All".

merchantListStore already exposes selectedCategory; categoryMapping
already exports filterMerchantsByCategory. Plug both into the existing
source-sync reactive between the search and full-list branches —
search and category are mutually exclusive (the store resets the
category when entering search mode), so the precedence chain is
in-search → category != "all" → all places.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy /map used localforage to cache the map bounds on every move
(debounced 1s) and restored them on the next visit. Returning users
landed back where they left off without needing a URL hash. The
MapLibre rewrite dropped the integration entirely, so every visit
started at the project default (Curaçao) or the IP-geo guess.

Restore it via a small viewportCache helper:

- loadCachedView() returns {lat, lng, zoom} or null, validated.
- saveCachedView({lat, lng, zoom}) writes through localforage,
  swallows errors (quota / IndexedDB unavailable).
- Versioned key (coords-next-v1) so a stale prod-Leaflet build's
  Leaflet LatLngBounds blob doesn't poison this branch.

Resolution chain is now hash → query → cached view → IP-geo →
defaults, and a debounced (1s) saveCachedView fires alongside the
existing persistViewportToHash on every moveend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MapLibre's bundled CSS hardcodes a white background on
.maplibregl-ctrl-group, so the rounded pill that holds zoom / locate /
nav / boost / data-refresh / basemap controls read as glaring white
blocks against any dark basemap or dark-mode UI. Legacy /map and
/communities/map handled the equivalent .leaflet-bar via explicit
dark-mode rules; the MapLibre rewrite never carried those over.

Add .dark variants to controls.css for the pill background, hover
state, separator borders, and an invert(1) filter on the icon glyphs
(MapLibre stock icons are dark-gray background-images on a child
<span>; our custom controls use <img> children — both are single-color
silhouettes that invert cleanly). controls.css is already imported by
all custom IControls so the rules ship on /map and /communities/map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ort #1003

The default MapLibre popup styling on /communities/map looked broken —
white box even in dark mode, the close X squeezed inside the content
padding so it sat awkwardly next to the community avatar, no real
border or shadow. Add a scoped communities-map.css with:

- Comfortable padding, border-radius, drop shadow.
- Close button repositioned to absolute top-right with its own
  square hit area and hover state.
- Dark-mode popup body (#06171c), text color, and a re-colored tip
  triangle (CSS triangle built from border-color, so all four sides
  are overridden to handle every anchor direction).

Scoped to .communities-map-page on the route wrapper so /map's
merchant drawer isn't affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four EventName union members weren't doing anything useful:

- fullscreen_click — no caller. The fullscreen toggle was removed
  in the MapLibre rewrite (decision was to skip re-implementing it),
  so post-merge this event has zero emitters. Dashboard still shows
  574/30d coming from prod-Leaflet users — that'll drain to zero.
- nearby_button_click — no caller anywhere, never appeared in the
  dashboard either. Type-only ghost from an earlier refactor.
- zoom_in_click / zoom_out_click — high volume (~10k/30d combined)
  but the data isn't actionable. Users zoom; that's a given. The
  signal didn't change any decision and was 12% of the event budget
  for no insight.

Remove both the union members and the two trackEvent callers
(zoomToNearbyLevel for zoom_in, the post-addControl DOM listener for
zoom_out). locate_click stays — it's a meaningful interaction signal
distinct from the user just panning the map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1003

Two small dark-mode polish misses from the previous CSS commits:

1. BasemapsControl renders an inline svg with stroke=currentColor as
   its layer-stack icon, but my invert filter selector only caught
   the <img> children. The svg stayed dark-on-dark in dark mode. Add
   a matching selector so the layer button looks like the others.

2. The community-map popup tip is a CSS triangle built from border
   colors: three sides transparent, one colored to make the visible
   face. Painting all four borders with the dark bg collapsed the
   triangle into a square — the arrow disappeared. Replace the
   blanket override with per-anchor selectors that mirror MapLibres
   stock rules (border-bottom for anchor-top variants, border-top
   for anchor-bottom variants, etc.) so only the visible face gets
   recolored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ross the tip #1003

The subtle white-08 border I added on the popup body sat on top of the
CSS triangle that forms the tip arrow — the popup-content's bottom
border drew a horizontal line across the base of the triangle, making
the arrow look notched. The box-shadow already provides enough
separation between the popup body and the dark basemap, so the border
isn't earning its keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

3 participants