# Compare Lab
> stack, reorder and cross-fade tile overlays from a TreeViewPro popover.
---
.. llms_copy::Compare Lab
.. toc::
### Overview
A tileset **comparison** surface: a `dl2.EasyButton` opens a Mantine `Popover`
holding a MUI TreeViewPro, and every interaction in that tree reconciles a stack
of `dl2.TileLayer` overlays on the map underneath — visibility, per-layer
opacity, z-order and deletion.
The interesting part is the **reconciler**. Overlays are never unmounted and
remounted as the tree changes; a clientside callback diffs the desired state
against what is already on the map and mutates only what differs. That is what
keeps a slider drag smooth instead of tearing down and rebuilding a TileLayer on
every frame.
The overlays here are synthetic — coloured SVG tiles seeded by a button — so the
page is a self-contained demonstration of the pattern rather than a dependency on
any particular imagery source.
### What this page demonstrates
1. **Consistent initial state.** The tree shows Esri World Imagery checked at
80 % opacity on first paint, *and* the layer is actually on the map. Getting
this right means the store's initial value must agree with the tree's initial
`selectedItems` — if the reducer is `prevent_initial_call=True` and the store
says `source_visible: False` while the tree says checked, the layer will not
appear until the user jiggles a control. Initialise both to the same truth.
2. **Light / dark basemap.** The basemap swaps between CARTO Positron and CARTO
Dark Matter with the app shell's colour-scheme toggle, driven by a clientside
callback so the change is instant.
3. **Multi-zoom overlays.** "Seed 3 fake gens" drops tiles at matching z14 / z15
/ z16 coordinates over Salt Lake City, so cross-zoom relationships line up and
the association-by-zoom logic has something real to chew on.
4. **Every tree interaction wired** — selection toggles overlay visibility,
sliders drive opacity, the kebab menu's Remove deletes both the tree leaf and
its overlay, and items are reorderable to drive z-order.
### Live demo
.. exec::docs.compare-lab.example
:code: false
### Source
```python
# File: docs/compare-lab/example.py
"""
Compare Lab — stack, reorder and cross-fade tile overlays from a
TreeViewPro popover.
A `dl2.EasyButton` opens a Mantine Popover holding a MUI TreeViewPro; every
interaction in that tree reconciles a stack of `dl2.TileLayer` overlays on the
map beneath it — visibility, per-layer opacity, z-order and deletion.
The interesting part is the reconciler. Overlays are never unmounted and
remounted as the tree changes; a clientside callback diffs the desired state
against what is already on the map and mutates only what differs. That is what
keeps a slider drag smooth instead of rebuilding a TileLayer every frame.
Overlays here are SYNTHETIC — coloured SVG placeholders seeded by a button — so
the page is self-contained and every interaction responds in under a second.
What this page demonstrates:
1. **Consistent initial state.** The tree shows Esri World Imagery checked at
80 % opacity on first paint AND the layer is actually on the map. The
store's initial value has to agree with the tree's initial `selectedItems`:
with a `prevent_initial_call=True` reducer, a store saying
`source_visible=False` while the tree says checked leaves the layer
invisible until the user jiggles a control. Both are initialised to the
same truth here (`source_visible: True`), so render-source-layer fires
correctly on first paint.
2. **Light/dark basemap.** The basemap auto-swaps between CARTO Positron
(light) and CARTO Dark Matter (dark) with the app-shell
`color-scheme-toggle`, driven by a clientside callback so it is instant.
3. **Multi-zoom overlays.** "Seed 3 fake gens" drops three coloured SVG tiles
at matching z14 / z15 / z16 coordinates over Salt Lake City, so cross-zoom
relationships line up and the association-by-zoom math has real input.
4. **All TreeViewPro interactions wired** — selection toggles overlay
visibility, sliders drive opacity, the kebab "Remove" deletes the leaf and
its overlay, and items are reorderable to drive z-order.
"""
from __future__ import annotations
import base64
import os
import time
import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import (
ALL,
Input,
Output,
State,
callback,
clientside_callback,
ctx,
dcc,
html,
no_update,
)
from dash_iconify import DashIconify
from dl2_tiles import POSITRON, register_theme_swap
from dl2_locations import SALT_LAKE_CITY
from dash_mui_charts import TreeViewPro
# ---------------------------------------------------------------------
# Constants — tile URLs, attributions, MUI key.
# ---------------------------------------------------------------------
# CARTO basemap (no `{s}` subdomain so the URL is stable for the
# clientside light/dark swap below — the swap just substitutes one URL
# template for the other).
# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = POSITRON
CARTO_LIGHT = TILES.url("light")
CARTO_DARK = TILES.url("dark")
CARTO_ATTR = (
'© '
"OpenStreetMap © "
'CARTO'
)
ESRI_SAT = (
"https://server.arcgisonline.com/ArcGIS/rest/services/"
"World_Imagery/MapServer/tile/{z}/{y}/{x}"
)
ESRI_ATTR = (
"Tiles © Esri — Esri, Maxar, Earthstar Geographics, "
"and the GIS User Community"
)
MUI_PRO_LICENSE_KEY = os.environ.get("MUI_PRO_API_KEY", "")
# The three synthetic generation coords. `nested_tile_keys` walks down from
# the real z14 tile containing the city, and the NW child of (x, y) is always
# (2x, 2y) — so the cross-zoom nesting the association math is testing is
# genuine, and the tiles actually sit over the city the map opens on.
SYNTH_KEYS = SALT_LAKE_CITY.nested_tile_keys(14, levels=3)
SYNTH_COLOR = ["#e64980", "#7950f2", "#15aabf"] # one color per zoom
# ---------------------------------------------------------------------
# Tiny shared helpers (copied — not imported — to keep the lab fully
# self-contained).
# ---------------------------------------------------------------------
def parse_key(key: str) -> tuple[int, int, int]:
z, x, y = key.split("/")
return int(z), int(x), int(y)
def _synth_data_url(label: str, color: str) -> str:
"""A 256x256 SVG square — used as the synthetic overlay image.
Inline data URL so no network fetch happens at render time."""
svg = (
f""
)
return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode()).decode()}"
def _synth_gen(key: str, idx: int) -> dict:
z, x, y = parse_key(key)
return {
"data_url": _synth_data_url(f"z{z}", SYNTH_COLOR[idx % len(SYNTH_COLOR)]),
"model": "synthetic",
"n": 1,
"source": key,
"accepted": True,
"ts": time.time() + idx,
}
def _build_tree(generations: dict) -> list[dict]:
"""Build the TreeViewPro model — Custom
Tileset group first (one leaf per generation, sorted by z, x, y),
Source Tileset group second (one leaf: Esri World Imagery)."""
keys = list((generations or {}).keys())
def _zxy(k: str):
try:
return (0, *parse_key(k))
except Exception:
return (1, k, 0, 0)
keys.sort(key=_zxy)
custom_children: list[dict] = []
for key in keys:
gen = (generations or {}).get(key)
if not (gen and gen.get("data_url")):
continue
z, x, y = parse_key(key)
custom_children.append({"id": f"cust:{key}", "label": f"z{z} · {x}/{y}"})
if not custom_children:
custom_children.append(
{"id": "cust:empty", "label": "(no generations — click Seed)"}
)
n_real = len([c for c in custom_children if c["id"] != "cust:empty"])
return [
{
"id": "grp.custom",
"label": f"Custom Tileset ({n_real})",
"children": custom_children,
},
{
"id": "grp.source",
"label": "Source Tileset",
"children": [
{"id": "src:esri", "label": "Esri World Imagery (satellite)"},
],
},
]
KEBAB_MENU = [
{"label": "Remove", "value": "delete", "icon": "Delete"},
]
# ---------------------------------------------------------------------
# Popover builder
# ---------------------------------------------------------------------
def _compare_popover():
return dmc.Popover(
id="cl-compare-popover",
opened=False,
position="right-start",
offset=6,
withArrow=True,
arrowSize=10,
shadow="lg",
radius="md",
closeOnClickOutside=True,
closeOnEscape=True,
keepMounted=True,
children=[
# Anchor — invisible 1×30 strip over the EasyButton.
dmc.PopoverTarget(
html.Div(id="cl-compare-anchor"),
boxWrapperProps={
"style": {
"position": "absolute",
"top": "60px",
"left": "46px",
"width": "1px",
"height": "30px",
"pointerEvents": "none",
"zIndex": 600,
}
},
),
dmc.PopoverDropdown(
dmc.Stack(
[
dmc.Group(
[
DashIconify(
icon="mdi:layers-search-outline",
width=16,
color="var(--mantine-color-blue-6)",
),
dmc.Text("Tileset comparison", fw=600, size="sm"),
dmc.Badge(
"LAB",
color="violet",
variant="light",
size="xs",
ml="auto",
),
],
gap="xs",
wrap="nowrap",
),
dmc.Divider(),
dmc.Text(
"Synthetic playground — selection / slider / "
"kebab / drag-reorder all update the map in real "
"time. No AI calls.",
size="xs",
c="dimmed",
),
html.Div(
TreeViewPro(
id="cl-compare-tree",
items=_build_tree({}),
defaultExpandedItems=["grp.source", "grp.custom"],
multiSelect=True,
checkboxSelection=True,
# MATCH the initial store value below — this is the
# fix for the "Source Tileset reads checked but
# isn't on the map" mismatch described in the module docstring.
selectedItems=["src:esri"],
isItemEditable=False,
itemsReordering=True,
showItemControls=True,
controlsItems=["src:esri"],
sliderValues={"src:esri": 80},
sliderMin=0,
sliderMax=100,
sliderStep=1,
sliderColor="blue",
kebabMenuItems=KEBAB_MENU,
licenseKey=MUI_PRO_LICENSE_KEY,
expandIcon="ChevronRight",
collapseIcon="ExpandMore",
itemChildrenIndentation="14px",
sx={
"& .MuiTreeItem-content": {"paddingY": "3px"},
"& .MuiTreeItem-label": {
"fontSize": "12px",
"width": "100%",
},
"& .MuiSlider-root": {
"height": "2px",
"padding": "8px 0",
},
},
),
className="tlp-tree", # reuse /tile-layers-pro tree polish
),
],
gap=10,
),
p="md",
style={"width": "360px"},
),
],
)
# ---------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------
component = dmc.Stack(
[
dmc.Group(
[
dmc.Stack(
[
dmc.Title("Compare Lab", order=2, mb=0),
dmc.Text(
"Isolated harness for the Tileset Comparison popover. "
"Initial render shows Esri satellite at 80 % on top of "
"CARTO (light or dark per the app theme). Seed synthetic "
"generations and exercise the TreeViewPro controls; the "
"map reflects every change immediately.",
size="sm",
c="dimmed",
),
],
gap=2,
),
dmc.Badge(
"polished pattern testing",
color="violet",
variant="light",
size="lg",
),
],
justify="space-between",
wrap="nowrap",
align="start",
),
dmc.Group(
[
dmc.Button(
"Seed 3 fake gens (z14 / z15 / z16)",
id="cl-seed",
leftSection=DashIconify(icon="mdi:auto-fix", width=16),
color="violet",
),
dmc.Button(
"Clear all",
id="cl-clear",
variant="light",
color="red",
leftSection=DashIconify(icon="mdi:trash-can-outline", width=16),
),
dmc.Tooltip(
label=(
"Synthetic overlays at z14/z15/z16, nested as ancestor/"
"descendant of each other so cross-zoom math is real. "
"Toggle / slider / kebab reorder + delete all wired."
),
multiline=True,
w=320,
withArrow=True,
position="bottom",
children=DashIconify(
icon="mdi:information-outline",
width=18,
color="var(--mantine-color-dimmed)",
),
),
],
gap="xs",
),
html.Div(
# Position-relative so the popover's absolute anchor lands over
# the EasyButton on the map.
style={"position": "relative", "height": "72vh"},
children=[
dmc.Paper(
dl2.Map(
id="cl-map",
center=SALT_LAKE_CITY.center,
zoom=11,
style={"height": "100%", "width": "100%"},
children=[
# Basemap — URL controlled by the clientside
# light/dark callback below.
dl2.TileLayer(
id="cl-basemap", url=CARTO_LIGHT, attribution=CARTO_ATTR
),
# Source Tileset slot — a Python callback fills
# this with the Esri TileLayer when source_visible
# is True (which it IS at page load — that's the
# fix the lab is demonstrating).
html.Div(id="cl-source-layer-slot"),
# Compare popover trigger.
dl2.EasyButton(
id="cl-compare-btn",
position="topleft",
icon="mdi:layers-search-outline",
iconSize=20,
title="Tileset comparison",
),
],
),
shadow="sm",
radius="md",
withBorder=True,
style={"overflow": "hidden", "height": "100%"},
),
_compare_popover(),
],
),
# ---- Stores ----
# Synthetic generations dict — the shape a real generation pipeline would emit.
dcc.Store(id="cl-generations", data={}, storage_type="memory"),
# Compare-popover state. CRITICAL: source_visible STARTS at True so
# the render-source-layer callback paints the Esri layer on the very
# first run — without this, the tree shows Esri checked but the map
# doesn't reflect it until the user nudges the slider or toggle.
dcc.Store(
id="cl-compare-state",
data={
"source_visible": True,
"source_opacity": 80,
"visible_customs": [],
"custom_opacities": {},
},
storage_type="memory",
),
# Dummy output for the clientside overlay reconciler — Dash requires
# a concrete Output target. Nothing reads it.
dcc.Store(id="cl-overlay-trigger", data=0, storage_type="memory"),
# Canonical open/closed state for the compare popover. See
# `manage_popover_state` below for why we bother with a separate
# Store instead of just using popover.opened directly.
dcc.Store(id="cl-popover-open", data=False, storage_type="memory"),
],
gap="md",
)
# =====================================================================
# Callbacks
# =====================================================================
# Basemap light/dark swap — the app's `color-scheme-toggle` is True when
# the LIGHT theme is active (see app.py header()), so checked=True → light.
register_theme_swap("cl-basemap", TILES)
# Source-layer slot. Critically NOT `prevent_initial_call` so it fires on
# page load with the initial source_visible=True and the Esri tile layer
# mounts right away.
@callback(
Output("cl-source-layer-slot", "children"),
Input("cl-compare-state", "data"),
)
def render_source_layer(state):
state = state or {}
if not state.get("source_visible"):
return []
opacity = state.get("source_opacity", 80) / 100.0
return [
dl2.TileLayer(
url=ESRI_SAT,
attribution=ESRI_ATTR,
opacity=opacity,
maxZoom=19,
)
]
# EasyButton → popover toggle.
#
# Two clientside callbacks, no cycle:
# 1. Button click reads the popover's CURRENT visibility from the DOM
# and flips the store. Reading the DOM at click time gives accurate
# state even after a click-outside or Escape close (Mantine's
# `opened` State prop is unreliable — verified empirically — but
# the rendered DOM always reflects the truth).
# 2. Store → popover.opened (one-way drive).
#
# DomEvent.disableClickPropagation on the EasyButton stops the click
# from bubbling to document, so Mantine's outside-click handler doesn't
# fire on button clicks — the DOM read is "is the dropdown currently
# visible" and the answer is the truth at the moment we want to act.
clientside_callback(
"""
(n) => {
if (!n) return window.dash_clientside.no_update;
const dd = document.querySelector('.mantine-Popover-dropdown');
const isOpen = !!(dd && dd.offsetParent !== null);
return !isOpen;
}
""",
Output("cl-popover-open", "data"),
Input("cl-compare-btn", "n_clicks"),
prevent_initial_call=True,
)
clientside_callback(
"(opened) => !!opened",
Output("cl-compare-popover", "opened"),
Input("cl-popover-open", "data"),
)
# TreeViewPro selection + sliders → compare-state.
# The reducer keeps visible_customs as a list, NOT
# a single boolean, so each custom leaf toggles independently). No-op
# guard skips identity-different-but-structurally-equal emissions to
# keep the cascade quiet.
@callback(
Output("cl-compare-state", "data"),
Input("cl-compare-tree", "selectedItems"),
Input("cl-compare-tree", "sliderValues"),
State("cl-compare-state", "data"),
prevent_initial_call=True,
)
def reduce_state(selected, sliders, state):
prev = dict(state or {})
new = dict(prev)
sel = set(selected or [])
sliders = sliders or {}
new["source_visible"] = "src:esri" in sel
new["visible_customs"] = sorted(
s[len("cust:") :] for s in sel if s.startswith("cust:") and s != "cust:empty"
)
if "src:esri" in sliders:
new["source_opacity"] = int(sliders["src:esri"])
new["custom_opacities"] = {
k[len("cust:") :]: int(v)
for k, v in sliders.items()
if k.startswith("cust:") and k != "cust:empty"
}
if new == prev:
return no_update
return new
# Derive tree items + slider seeds from generations. The `items` no-op
# guard is what stops MUI from re-rendering the tree on every
# generations update (which would briefly reset selectedItems → the
# "vanishing tile" flicker).
@callback(
Output("cl-compare-tree", "items"),
Output("cl-compare-tree", "controlsItems"),
Output("cl-compare-tree", "sliderValues"),
Input("cl-generations", "data"),
State("cl-compare-state", "data"),
State("cl-compare-tree", "items"),
)
def derive_tree(generations, state, current_items):
state = state or {}
items = _build_tree(generations or {})
if items == (current_items or []):
return no_update, no_update, no_update
opacities = state.get("custom_opacities") or {}
controls = ["src:esri"]
sliders = {"src:esri": state.get("source_opacity", 80)}
for grp in items:
if grp["id"] == "grp.custom":
for leaf in grp.get("children") or []:
if leaf["id"] == "cust:empty":
continue
controls.append(leaf["id"])
key = leaf["id"][len("cust:") :]
sliders[leaf["id"]] = opacities.get(key, 90)
return items, controls, sliders
# Auto-select new generations into selectedItems so a freshly-seeded
# overlay shows up on the map without the user opening the popover.
@callback(
Output("cl-compare-tree", "selectedItems", allow_duplicate=True),
Input("cl-generations", "data"),
State("cl-compare-tree", "selectedItems"),
prevent_initial_call=True,
)
def auto_select(gens, selected):
selected = list(selected or [])
sel_set = set(selected)
changed = False
for key, gen in (gens or {}).items():
if not (gen and gen.get("data_url")):
continue
leaf_id = f"cust:{key}"
if leaf_id not in sel_set:
selected.append(leaf_id)
sel_set.add(leaf_id)
changed = True
return selected if changed else no_update
# Seed three synthetic generations at the known nested coords.
@callback(
Output("cl-generations", "data"),
Input("cl-seed", "n_clicks"),
State("cl-generations", "data"),
prevent_initial_call=True,
)
def seed_gens(_, gens):
gens = dict(gens or {})
for i, key in enumerate(SYNTH_KEYS):
gens[key] = _synth_gen(key, i)
return gens
# Clear → wipe generations + reset selection + reset state to initial.
@callback(
Output("cl-generations", "data", allow_duplicate=True),
Output("cl-compare-tree", "selectedItems", allow_duplicate=True),
Output("cl-compare-state", "data", allow_duplicate=True),
Input("cl-clear", "n_clicks"),
prevent_initial_call=True,
)
def clear_all(_):
return (
{},
["src:esri"],
{
"source_visible": True,
"source_opacity": 80,
"visible_customs": [],
"custom_opacities": {},
},
)
# Kebab — "Remove" is the only action this page needs.
@callback(
Output("cl-generations", "data", allow_duplicate=True),
Output("cl-compare-tree", "selectedItems", allow_duplicate=True),
Input("cl-compare-tree", "kebabAction"),
State("cl-generations", "data"),
State("cl-compare-tree", "selectedItems"),
prevent_initial_call=True,
)
def kebab(action, gens, selected):
if not action:
return no_update, no_update
item_id = action.get("itemId", "")
what = action.get("action")
if not item_id.startswith("cust:") or item_id == "cust:empty" or what != "delete":
return no_update, no_update
key = item_id[len("cust:") :]
new_gens = dict(gens or {})
new_gens.pop(key, None)
new_sel = [s for s in (selected or []) if s != item_id]
return new_gens, new_sel
# Clientside overlay reconciler — same mount-everything-hide-via-opacity
# reconciler pattern: EVERY generation stays mounted; visibility
# is purely a `setOpacity` toggle based on visible_customs membership
# AND zoom match. Immune to the MUI selectedItems flicker.
clientside_callback(
"""
(state, generations) => {
const log = (msg, data) => {
try { console.log('[compare-lab] ' + msg, data ?? ''); } catch (e) {}
};
const root = document.getElementById('cl-map');
const map = root && root.__dl2_map;
if (!map) { log('skip: no map handle'); return window.dash_clientside.no_update; }
const L = window.leaflet || window.L;
if (!L) { log('skip: no Leaflet'); return window.dash_clientside.no_update; }
const visible = new Set((state && state.visible_customs) || []);
const opacities = (state && state.custom_opacities) || {};
if (!window._cl_overlays_by_key) window._cl_overlays_by_key = {};
const reg = window._cl_overlays_by_key;
const TILE = 256;
// Latest state for the zoomend listener (attached once but needs
// fresh values on every map zoom).
window._cl_compare_visible = visible;
window._cl_compare_opacities = opacities;
// 1) Remove only when the underlying generation is gone.
let removed = 0;
for (const key of Object.keys(reg)) {
if (!generations || !generations[key] || !generations[key].data_url) {
try { reg[key].overlay.remove(); } catch (e) {}
delete reg[key];
removed += 1;
}
}
// 2) Mount or update every generation (at opacity 0 — applyVis
// sets the right value next).
let added = 0, updated = 0;
for (const [key, gen] of Object.entries(generations || {})) {
if (!gen || !gen.data_url) continue;
const parts = key.split('/').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) continue;
const [z, x, y] = parts;
const baseOpacity = ((opacities[key] ?? 90)) / 100;
if (!reg[key]) {
const nw = map.unproject([x * TILE, y * TILE], z);
const se = map.unproject([(x + 1) * TILE, (y + 1) * TILE], z);
const bounds = [[se.lat, nw.lng], [nw.lat, se.lng]];
const overlay = new L.ImageOverlay(gen.data_url, bounds, {
opacity: 0, interactive: false,
className: 'cl-compare-overlay',
});
overlay.addTo(map);
overlay._clKey = key;
reg[key] = { overlay, dataUrl: gen.data_url,
baseOpacity, tileZ: z };
added += 1;
} else {
const entry = reg[key];
if (entry.dataUrl !== gen.data_url) {
try { entry.overlay.setUrl(gen.data_url); } catch (e) {}
entry.dataUrl = gen.data_url;
updated += 1;
}
if (entry.baseOpacity !== baseOpacity) {
entry.baseOpacity = baseOpacity;
updated += 1;
}
}
}
window._cl_overlays = Object.values(reg).map(e => {
const o = e.overlay;
o._clTileZ = e.tileZ;
o._clBaseOpacity = e.baseOpacity;
return o;
});
// Visibility = visible_customs membership only. No strict zoom
// matching — that confuses users (toggling a
// leaf at the wrong zoom looked like "nothing happened"). Geo-
// graphic bounds keep each overlay at its TRUE scale, so smaller
// tiles naturally nest inside larger ones (Russian doll), and
// the user can compare across zooms simultaneously.
const applyVis = () => {
const vis = window._cl_compare_visible || new Set();
const ops = window._cl_compare_opacities || {};
for (const o of window._cl_overlays) {
const baseOp = (ops[o._clKey] ?? 90) / 100;
o.setOpacity(vis.has(o._clKey) ? baseOp : 0);
}
};
applyVis();
// (zoomend listener no longer needed — visibility is zoom-
// independent now.)
log('done', {added, updated, removed,
mounted: window._cl_overlays.length,
visible: Array.from(visible)});
return window._cl_overlays.length;
}
""",
Output("cl-overlay-trigger", "data"),
Input("cl-compare-state", "data"),
Input("cl-generations", "data"),
)
```
:defaultExpanded: false
:withExpandedButton: true
---
*Source: /compare-lab*