Choose Between the Grid and Mosaic Layouts¶
Two layouts place symbols on a tile lattice: "grid" and "mosaic". They look
similar in a plot and take similar arguments, but they solve different problems.
This guide runs both on the same inputs, measures what each one preserves, and
ends with a decision table.
The short version:
"grid"— one symbol per region, modest region counts, and the highest per-region fidelity: each region lands close to where it belongs and keeps its neighbors and their compass directions."mosaic"— many tiles per region, grouped regions, large region counts, and anywhere the overall outline of the tilegram should still read as the map.
See the tutorial for a basic introduction to symbol cartograms.
import time
from collections import deque
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from shapely.affinity import scale as shp_scale
from shapely.affinity import translate as shp_translate
from shapely.ops import unary_union
import carto_flow.symbol_cartogram as sym
from carto_flow.data import load_us_census
from carto_flow.symbol_cartogram.adjacency import compute_adjacency
states = load_us_census(population=True)
print(f"{len(states)} states")
49 states
Measuring what each layout preserves¶
The numbers below are computed from the LayoutResult alone, so both layouts are
scored by identical code. Four quantities:
- adjacency — fraction of the input's neighboring region pairs that are still neighbors on the lattice
- displacement — median distance from a region's centroid to the center of its tile block, in tile widths
- reversed — fraction of neighboring pairs whose direction turned by more than 90 degrees, so the neighbor ended up on the opposite side
- scattered — regions whose tiles do not form one edge-connected block
- silhouette — overlap (IoU) between the occupied tile footprint and the outline of the original map, after scaling the footprint to equal area and aligning centers, so a layout cannot score better simply by using bigger tiles
- seconds — wall clock
def region_tiles(result, n_regions):
"""Map each region index to the lattice tiles assigned to it."""
tiles = np.asarray(result.assignments, dtype=int)
src = result.source_indices
regions = np.asarray(src, dtype=int) if src is not None else np.arange(len(tiles))
blocks = [[] for _ in range(n_regions)]
for tile, region in zip(tiles, regions, strict=False):
blocks[region].append(int(tile))
return blocks
def is_scattered(adjacency, block):
"""True when a region's tiles form more than one connected block."""
if len(block) < 2:
return False
members, seen, queue = set(block), {block[0]}, deque([block[0]])
while queue:
for other in np.flatnonzero(adjacency[queue.popleft()]):
if int(other) in members and int(other) not in seen:
seen.add(int(other))
queue.append(int(other))
return len(seen) != len(members)
def scattered_regions(result, n_regions):
"""Indices of regions whose tiles are not one connected block."""
adjacency = result.tiling_result.adjacency
return [i for i, block in enumerate(region_tiles(result, n_regions)) if is_scattered(adjacency, block)]
def block_centers(result, n_regions):
"""Mean tile center of every region's block."""
centers = result.tiling_result.centers
return np.array([centers[block].mean(axis=0) for block in region_tiles(result, n_regions)])
def neighbor_pairs(gdf):
"""Index pairs of regions that touch in the input."""
return list(zip(*np.triu(compute_adjacency(gdf), 1).nonzero(), strict=False))
def adjacency_preserved(result, gdf, pairs):
"""Fraction of input neighbor pairs that remain neighbors on the lattice."""
tile_adjacency = result.tiling_result.adjacency
blocks = region_tiles(result, len(gdf))
kept = sum(1 for i, j in pairs if tile_adjacency[np.ix_(blocks[i], blocks[j])].any())
return kept / len(pairs)
def direction_reversed(result, gdf, pairs, centroids):
"""Fraction of neighbor pairs whose direction turned by more than 90 degrees."""
placed = block_centers(result, len(gdf))
turns = []
for i, j in pairs:
before = np.arctan2(*(centroids[j] - centroids[i])[::-1])
after = np.arctan2(*(placed[j] - placed[i])[::-1])
turns.append(abs(np.degrees((after - before + np.pi) % (2 * np.pi) - np.pi)))
return float(np.mean(np.array(turns) > 90))
def displacement(result, gdf, centroids):
"""Median centroid-to-block distance, in tile widths."""
offsets = np.linalg.norm(block_centers(result, len(gdf)) - centroids, axis=1)
return float(np.median(offsets) / result.tiling_result.tile_size)
def silhouette_iou(result, n_regions, outline):
"""Overlap of the tile footprint with the map outline, at matched area."""
polygons = result.tiling_result.polygons
footprint = unary_union([polygons[t] for block in region_tiles(result, n_regions) for t in block])
factor = (outline.area / footprint.area) ** 0.5
aligned = shp_scale(footprint, factor, factor, origin="centroid")
aligned = shp_translate(aligned, outline.centroid.x - aligned.centroid.x, outline.centroid.y - aligned.centroid.y)
return aligned.intersection(outline).area / aligned.union(outline).area
def compare(gdf, outline, **kwargs):
"""Run both layouts on the same input and score them with identical code."""
centroids = np.array([[g.centroid.x, g.centroid.y] for g in gdf.geometry])
pairs = neighbor_pairs(gdf)
results, rows = {}, []
for layout in ("grid", "mosaic"):
start = time.perf_counter()
result = sym.create_layout(gdf, layout=layout, show_progress=False, **kwargs)
seconds = time.perf_counter() - start
results[layout] = result
rows.append({
"adjacency": round(adjacency_preserved(result, gdf, pairs), 3),
"displacement": round(displacement(result, gdf, centroids), 2),
"reversed": round(direction_reversed(result, gdf, pairs, centroids), 3),
"scattered": len(scattered_regions(result, len(gdf))),
"silhouette": round(silhouette_iou(result, len(gdf), outline), 3),
"seconds": round(seconds, 1),
})
return results, pd.DataFrame(rows, index=["grid", "mosaic"])
One symbol per region¶
With no tile_count, both layouts give every region exactly one tile. This is the
classic tile map, and what the tile_map_cartogram and demers_cartogram presets
produce.
outline = unary_union(states.geometry.values)
one_per_region, scores = compare(states, outline)
grid_1to1, mosaic_1to1 = one_per_region["grid"], one_per_region["mosaic"]
scores
| adjacency | displacement | reversed | scattered | silhouette | seconds | |
|---|---|---|---|---|---|---|
| grid | 0.789 | 1.22 | 0.000 | 0 | 0.609 | 1.2 |
| mosaic | 0.725 | 1.53 | 0.064 | 0 | 0.713 | 1.0 |
fig, axes = plt.subplots(1, 2, figsize=(13, 5), sharex=True, sharey=True)
for ax, result, title in zip(axes, [grid_1to1, mosaic_1to1], ["grid", "mosaic"], strict=False):
states.boundary.plot(ax=ax, color="0.85", linewidth=0.5)
result.style().plot(
ax=ax,
source_gdf=states,
facecolor="#4e79a7",
edgecolor="white",
linewidth=0.5,
alpha=0.85,
label="State Abbreviation",
label_color="white",
label_fontsize=7,
)
ax.set(title=f"{title} — one tile per state")
ax.axis("off")
plt.tight_layout()
Grid wins every per-region column: more of the input's neighbor pairs stay adjacent, states sit closer to where they actually are, and no pair of neighbors ends up on opposite sides of each other. That is what its cost function optimizes.
Mosaic wins the silhouette. Its lattice is calibrated to the area it has to cover, so the tiles fill out the shape of the country; grid sizes its lattice from the largest symbol on a deliberately oversized grid, and leaves interior gaps and spurs. The labeled figure shows both effects at once — grid's arrangement reads more accurately state by state, mosaic's reads more accurately as a map of the United States.
So at one symbol per region the choice is between per-region fidelity (grid) and overall silhouette (mosaic).
Many tiles per region¶
With tile_count, each region asks for an integer number of tiles. Here each state
gets a share of 150 tiles proportional to its population.
tiled = states.copy()
population = tiled["Population"].values.astype(float)
tiled["tiles"] = np.maximum(1, np.round(population / population.sum() * 150)).astype(int)
print(f"{tiled['tiles'].sum()} tiles for {len(tiled)} states")
many_per_region, scores_many = compare(tiled, outline, tile_count="tiles")
grid_many, mosaic_many = many_per_region["grid"], many_per_region["mosaic"]
scores_many
154 tiles for 49 states
| adjacency | displacement | reversed | scattered | silhouette | seconds | |
|---|---|---|---|---|---|---|
| grid | 0.780 | 2.43 | 0.083 | 22 | 0.678 | 3.3 |
| mosaic | 0.798 | 2.10 | 0.009 | 0 | 0.687 | 0.9 |
Both layouts hand out the requested number of tiles, but only mosaic keeps each state's tiles together. Below, tiles of a scattered state — one whose tiles form more than one block — are outlined in red.
palette = plt.get_cmap("tab20").colors
def plot_scattered(result, gdf, ax, title):
"""Draw the tilegram, one color per region, scattered regions outlined red."""
scattered = set(scattered_regions(result, len(gdf)))
styled = result.style()
source = styled.symbols["original_index"].values
facecolors = [palette[i % len(palette)] for i in source]
edgecolors = ["#d62728" if i in scattered else "white" for i in source]
gdf.boundary.plot(ax=ax, color="0.85", linewidth=0.5)
styled.plot(ax=ax, facecolor=facecolors, edgecolor=edgecolors, linewidth=1.0)
ax.set(title=f"{title} — {len(scattered)} of {len(gdf)} states scattered")
ax.axis("off")
fig, axes = plt.subplots(1, 2, figsize=(13, 5), sharex=True, sharey=True)
plot_scattered(grid_many, tiled, axes[0], "grid")
plot_scattered(mosaic_many, tiled, axes[1], "mosaic")
plt.tight_layout()
Scattered regions are the sharpest difference between the two. The grid layout places each requested tile as a separate item and has nothing tying a region's tiles together, so a state that needs many of them ends up with strays sitting apart from its main block. Mosaic assigns tiles in blocks and repairs connectivity afterwards, so a region's tiles stay in one piece.
Once regions span several tiles, mosaic also overtakes grid on the per-region columns that grid led at one tile per region — its pre-morph gives each region roughly the area its tile count asks for before any tile is assigned.
Grid is the slower of the two here, and the gap widens with region count: its refinement loop rescores every pair of regions on every pass, so a few hundred regions take it minutes where mosaic takes seconds.
Grouped regions¶
group_by labels each region with a group — districts within a state, states within
a region — and asks the layout to keep a group's symbols together. Grid does not
read the grouping at all, and says so rather than silently ignoring it.
try:
sym.create_layout(states, group_by="Region", layout="grid", show_progress=False)
except ValueError as exc:
print(exc)
Layout GridBasedLayout does not support group_by: it would ignore the grouping and place symbols as if it were absent. Layouts that honor group_by: centroid, flow_density, mosaic, packing. Drop group_by or use one of those layouts.
Mosaic honors the grouping: it constrains a group's tiles to a single block and repairs any split it finds.
grouped = sym.create_layout(states, group_by="Region", layout="mosaic", show_progress=False)
region_color = {"Northeast": "#4e79a7", "Midwest": "#f28e2b", "South": "#e15759", "West": "#76b7b2"}
source = grouped.style().symbols["original_index"].values
facecolors = [region_color[states["Region"].iloc[i]] for i in source]
fig, ax = plt.subplots(figsize=(8, 5))
states.boundary.plot(ax=ax, color="0.85", linewidth=0.5)
grouped.style().plot(ax=ax, facecolor=facecolors, edgecolor="white", linewidth=0.5)
ax.set(title="mosaic with group_by='Region'")
ax.axis("off")
plt.tight_layout()
Capability differences¶
Some differences are absolute rather than a matter of degree:
"grid" |
"mosaic" |
|
|---|---|---|
group_by |
raises — the grouping cannot affect placement | honored: a group's tiles form one block |
tile_count |
accepted; a region's tiles may end up scattered | each region gets exactly that many tiles, in one block |
size |
sets symbol size — the data encoding at one tile per region | scales symbols inside their tiles; does not affect which tiles a region gets |
morph |
not available | on by default: geometries are pre-morphed toward their tile counts |
size_normalization |
defaults to "max", which keeps the lattice at the scale of the input |
no effect — symbol scale comes from the tile lattice |
| Presets | demers_cartogram (squares), tile_map_cartogram (hexagons) |
none; construct it directly |
Both layouts return a TiledLayoutResult, so plot_tiling(), to_geodataframe()
and per-group styling work the same way for either.
Decision guide¶
| Situation | Layout |
|---|---|
| One symbol per region, a few dozen regions | "grid" |
| Neighbor relationships and compass directions must survive | "grid" |
| A Demers cartogram or a classic hexagon tile map | "grid" (via the presets) |
| A tilegram: each region several tiles, all of them held together | "mosaic" |
Regions grouped with group_by |
"mosaic" |
| Several hundred regions or more | "mosaic" |
| The tilegram's outline should still read as the map | "mosaic" |
When neither column obviously applies, run both — they take the same arguments, and the helpers above score them on your own data.
See Also¶
- Create a Tile-Count Map —
tile_countandgroup_byin practice - Explanation: Grid Layout Algorithm
- Explanation: Mosaic Layout Algorithm
- Reference: Layout