---
title: 'Geographic Visualization'
source: 'https://academia.sh/en/courses/visualization-reporting-and-analysis-at-scale/geographic-visualization'
course: 'Visualization, Reporting and Analysis at Scale'
language: en
updated: '2026-10-07T04:31:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Models the regions as a coverage grid and counts the read-back error per region of the color step channel'
    - 'Computes the number read back in a choropleth display with the area weight and prints its difference from the correct number'
    - "Measures how projection stretching changes the printed area by region and shows that the direction of the error is not under the designer's control"
    - 'Counts how much a correction that makes the area share equal to the read share lowers the error and how much it takes away from the adjacency structure'
---

# Geographic Visualization

Five regions are encoded on a 12x12 coverage grid with five color steps, and what the reader reads back is not the step but the area the step covers: the correct mean weighted by the number of readings is 19.713 m³, while the read-back weighted by area gives 18.484 m³, a difference of -1.228 m³. West carries 14.9% of the readings and 25% of the area, so it gets a weight factor of 1.680. Projection stretching suppresses two regions of equal area by a factor of 1.237 relative to each other. When the area share is made equal to the read share, the error falls to 0.396 m³, at the price of 93 cells changing region and the pairs of neighboring regions dropping from 8 to 4.

The previous lesson turned the axis into time and measured that what the channel carries is change.
Every reading of the measurement network is also taken at a place, and that place is not a
one-dimensional axis but a two-dimensional area. In a display built on an area, the value is turned
into a color step and the reader reads the step back. But what enters the reader's eye is not the
step but **the area the step covers**.

This is the measure of this lesson: the difference between the number read back from a choropleth
display and the number that exists in the data, and how this difference is proportional to the area
of the regions. How many distinct levels the color step channel gets read back was measured in the
relationship visualization lesson; here the number of steps is held fixed at five and the variable is
the area. No real place, map product or projection name is written; the coverage area is modeled as
a grid.

- **CD31. The coverage grid is fictional.** The regions of the network are divided into a grid of
  12x12 cells; each cell is a coverage unit, and the division is written in the code.
- **CD32. In a choropleth display the channel is the color step.** The encoder maps the value to
  one of five equal-width slices and the decoder maps it to the middle of the slice; the reading
  resolution is the slice width, and because the reader cannot distinguish the position within the
  slice, this number gives the share that cannot be read back.
- **CD33. The read-back is weighted by area.** The mean read at a glance is the mean of the region
  means weighted by the number of cells. The correct number is the one weighted by the number of
  readings.
- **CD34. The weight factor is the region's area share divided by its read share.** If the factor is
  greater than one, the region is read more than it deserves; if it is smaller, less than it
  deserves.
- **CD35. Projection stretching does not print equal areas equal.** When a curved surface is printed
  onto a rectangular grid, the rows are not printed at equal width; the stretch factor is a function
  of the row number in the model and is written in the code.
- **CD36. The area share can be corrected, the adjacency cannot.** When the cells are distributed in
  proportion to the read share, the area weight disappears, but the information about which region
  neighbors which on the grid is spoiled, and the damage is measured by the number of pairs.

## The Grid and the Color Step

```python
# grid.py — MODEL: the measurement network of K03-K05 is generated
# with the same seed. The coverage area of the network is also FICTIONAL: it is
# a 12x12 grid of cells and every cell is a coverage unit. No real place, map
# or projection is mentioned.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
PERIODS = ["2026-01", "2026-02", "2026-03"]
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


_kept, _dropped = [], []
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05])
    pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17])
    (_dropped if r() < 0.046 else _kept).append(
        {"subscriber_id": 10001 + i, "region": b[0], "base": b[2]})
VALID = {a["subscriber_id"] for a in _kept}

READINGS = []
for a in _kept + _dropped:
    r = rng(SEED + 7000 + a["subscriber_id"])
    for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        t = 0.0 if r() < 0.038 else math.floor(
            a["base"] * math.exp((r() + r() + r() - 1.5) * 0.62)
            * (1 - d * 0.05) * 100 + 0.5) / 100
        if a["subscriber_id"] not in VALID:
            r()
            continue
        READINGS.append({"subscriber_id": a["subscriber_id"],
                         "region": a["region"], "period": PERIODS[d],
                         "consumption_m3": t})
        r()

NAMES = [b[0] for b in REGION]
COUNT = {a: sum(1 for o in READINGS if o["region"] == a) for a in NAMES}
VALUE = {a: sum(o["consumption_m3"] for o in READINGS if o["region"] == a) / COUNT[a]
         for a in NAMES}

# Coverage grid: 12x12, every cell belongs to a region. A fictional division.
SIZE, STEPS = 12, 5


def cell_region(i, j):
    if i < 3:
        return "north"
    if j < 4:
        return "west"
    if i >= 8:
        return "south"
    if j >= 9:
        return "east"
    return "central"


GRID = [[cell_region(i, j) for j in range(SIZE)] for i in range(SIZE)]
AREA = {a: sum(r.count(a) for r in GRID) for a in NAMES}
LOW, HIGH = min(VALUE.values()), max(VALUE.values())
WIDTH = (HIGH - LOW) / STEPS


def encode(v):
    """Encoder: value -> color step. STEPS slices of equal width."""
    return min(STEPS - 1, int((v - LOW) / WIDTH))


def decode(b):
    """Decoder: color step -> value. The reader reads the middle of the slice."""
    return LOW + (b + 0.5) * WIDTH


print(f"grid {SIZE}x{SIZE} = {SIZE * SIZE} cells, {STEPS} color steps, "
      f"reading resolution {WIDTH:.3f} m3/step")
print(f"{'region':7s} {'area(cells)':>11s} {'count':>6s} {'mean':>9s} {'step':>8s} "
      f"{'read-back':>12s} {'error':>6s}")
for a in NAMES:
    b = encode(VALUE[a])
    print(f"{a:7s} {AREA[a]:11d} {COUNT[a]:6d} {VALUE[a]:9.3f} {b:8d} "
          f"{decode(b):12.3f} {decode(b) - VALUE[a]:+6.3f}")
for i in range(SIZE):
    print("       " + "".join(str(encode(VALUE[GRID[i][j]])) for j in range(SIZE)))
```

```
grid 12x12 = 144 cells, 5 color steps, reading resolution 2.179 m3/step
region  area(cells)  count      mean     step    read-back  error
north            36    921    20.210        2       19.226 -0.985
south            32    645    16.602        1       17.046 +0.445
east             15    557    24.674        4       23.584 -1.090
west             36    476    13.778        0       14.867 +1.090
central          25    600    22.396        3       21.405 -0.991
       222222222222
       222222222222
       222222222222
       000033333444
       000033333444
       000033333444
       000033333444
       000033333444
       000011111111
       000011111111
       000011111111
       000011111111
```

The step column is the whole of the channel: five regions fell on five different steps and each one
can be told apart. The read-back error per region is between -1.090 and +1.090 m³, that is, at most
half the slice width. This is the known and bounded price of the color step channel.

The grid at the bottom shows the real problem. The step of east is 4, the highest value of the grid,
but it covers only 15 cells on the page. The step of west is 0 and it covers 36 cells. Someone
looking at the same grid gets the impression "this network consumes little"; the step that covers the
most room on the page is the lowest one. The error per region reports this in no way, because the
error is measured region by region and the reader does not look region by region.

## Area Weight

```python
# weight.py — what the reader reads back at a glance is not the step but the
# area the step covers. Two means are put side by side.
TOTAL = sum(COUNT.values())
correct = sum(VALUE[a] * COUNT[a] for a in NAMES) / TOTAL
glance = sum(decode(encode(VALUE[a])) * AREA[a] for a in NAMES) / (SIZE * SIZE)
plain = sum(decode(encode(VALUE[a])) for a in NAMES) / len(NAMES)
print(f"mean weighted by readings         {correct:.3f} m3  ({TOTAL} readings)")
print(f"plain mean per region             {plain:.3f} m3  ({len(NAMES)} regions)")
print(f"read-back weighted by area        {glance:.3f} m3  ({SIZE * SIZE} cells)")
print(f"difference opened by area weight  {glance - correct:+.3f} m3")
print(f"{'region':7s}{'read share':>11s}{'area share':>11s}{'weight factor':>17s}")
for a in NAMES:
    rs, ash = COUNT[a] / TOTAL, AREA[a] / (SIZE * SIZE)
    print(f"{a:7s} {rs * 100:9.1f}% {ash * 100:9.1f}% {ash / rs:16.3f}")
```

```
mean weighted by readings         19.713 m3  (3199 readings)
plain mean per region             19.226 m3  (5 regions)
read-back weighted by area        18.484 m3  (144 cells)
difference opened by area weight  -1.228 m3
region  read share area share    weight factor
north        28.8%      25.0%            0.868
south        20.2%      22.2%            1.102
east         17.4%      10.4%            0.598
west         14.9%      25.0%            1.680
central      18.8%      17.4%            0.926
```

Three means, from the same data and none of them miscalculated. The mean of the 3,199 readings is
19.713 m³; the plain mean per region is 19.226; the one read from the choropleth display is 18.484.
The difference opened by the area weight is **-1.228 m³**, and not a single reading changed.

The table at the bottom writes where the difference comes from, region by region. West carries 14.9%
of the readings but covers 25% of the area; its weight factor is **1.680**, that is, on the page it
speaks about twice as loudly as its real share. East, on the contrary, with a factor of 0.598, speaks
at a little more than half of its share. West having the lowest mean and east the highest is a
coincidence, but when two coincidences work in the same direction the number read back shifts to one
side.

This is not a flaw independent of the choice of channel; it is **the definition of the area
channel**. The color step is painted onto the area, and the area is already fixed and has nothing to
do with the data. The display applies a weighting the designer did not choose, and writes that
weight nowhere.

## Projection Stretching

```python
# stretch.py — when a curved surface is printed onto a rectangular grid, the
# rows are not printed at equal width. The stretch factor is a MODEL: upper
# rows are printed wider.
CURVE, REF = 0.07, SIZE - 1


def stretch(i):
    return 1.0 / math.cos(CURVE * (REF - i))


PRINTED = {a: sum(stretch(i) for i in range(SIZE) for j in range(SIZE)
                  if GRID[i][j] == a) for a in NAMES}
TOTAL_AREA = sum(PRINTED.values())
stretched = sum(decode(encode(VALUE[a])) * PRINTED[a] for a in NAMES) / TOTAL_AREA
print(f"stretch factor in the top row {stretch(0):.3f}, in the bottom row "
      f"{stretch(SIZE - 1):.3f}")
print(f"{'region':7s}{'true area':>12s}{'printed area':>14s}{'growth':>8s}")
for a in NAMES:
    print(f"{a:7s} {AREA[a]:11d} {PRINTED[a]:13.2f} "
          f"{PRINTED[a] / AREA[a] - 1:+7.1%}")
print(f"north and west have equal areas ({AREA['north']} cells) but their printed "
      f"areas differ by a factor of {PRINTED['north'] / PRINTED['west']:.3f}")
print(f"area-weighted read-back before stretching {glance:.3f} m3, "
      f"after {stretched:.3f} m3")
print(f"distance to the correct number: {abs(glance - correct):.3f} -> "
      f"{abs(stretched - correct):.3f} m3")
```

```
stretch factor in the top row 1.393, in the bottom row 1.000
region    true area  printed area  growth
north            36         47.26  +31.3%
south            32         32.28   +0.9%
east             15         16.54  +10.3%
west             36         38.19   +6.1%
central          25         27.57  +10.3%
north and west have equal areas (36 cells) but their printed areas differ by a factor of 1.237
area-weighted read-back before stretching 18.484 m3, after 18.579 m3
distance to the correct number: 1.228 -> 1.133 m3
```

Stretching is a second distortion on top of the area weight, and this time it is not even the
designer's decision: it is the unavoidable price of printing a curved surface onto a flat grid. In
the model the top row is printed 1.393 times as wide as the bottom row.

The fifth line is the clearest statement of the measure. North and west are **of equal area**, both
36 cells. When printed they come to 47.26 and 38.19, that is, two regions of the same size look
**1.237 times** different in size. A reader has no basis whatsoever for comparing the weight of the
two regions.

The last two lines must be read carefully. In this fictional setup stretching **lowers** the total
error from 1.228 to 1.133 m³. This is not a correction but a coincidence: the region that stretches
the most is north, and because the read-back value of north is close to the correct number, it has
pulled the mean upward. The sign of the stretching depends on where the regions stand on the grid
and is not under the designer's control. Its lowering the error is as accidental as its raising it;
what has to be measured is not the total but the deviation of the printed area per region.

## Correcting the Area Share

```python
# cartogram.py — the cells are redistributed in proportion to the read share.
# The area weight is corrected, the adjacency structure of the grid is spoiled.
quota = {a: COUNT[a] * SIZE * SIZE / TOTAL for a in NAMES}
share = {a: int(quota[a]) for a in NAMES}
for a in sorted(NAMES, key=lambda x: quota[x] - int(quota[x]), reverse=True):
    if sum(share.values()) < SIZE * SIZE:
        share[a] += 1
stream = [a for a in NAMES for _ in range(share[a])]
CARTO = [stream[i * SIZE:(i + 1) * SIZE] for i in range(SIZE)]
NEW = {a: sum(r.count(a) for r in CARTO) for a in NAMES}
cartoview = sum(decode(encode(VALUE[a])) * NEW[a] for a in NAMES) / (SIZE * SIZE)
moved = sum(1 for i in range(SIZE) for j in range(SIZE)
            if GRID[i][j] != CARTO[i][j])


def adjacency(g):
    c = set()
    for i in range(SIZE):
        for j in range(SIZE):
            for di, dj in ((0, 1), (1, 0)):
                x, y = i + di, j + dj
                if x < SIZE and y < SIZE and g[i][j] != g[x][y]:
                    c.add(tuple(sorted((g[i][j], g[x][y]))))
    return c


k1, k2 = adjacency(GRID), adjacency(CARTO)
print(f"{'region':7s}{'old area':>10s}{'cartogram area':>16s}{'read share':>12s}")
for a in NAMES:
    print(f"{a:7s} {AREA[a]:9d} {NEW[a]:15d} {COUNT[a] / TOTAL * 100:10.1f}%")
for i in range(SIZE):
    print("       " + "".join(str(encode(VALUE[CARTO[i][j]])) for j in range(SIZE)))
print(f"area-weighted read-back {glance:.3f} -> {cartoview:.3f} m3, "
      f"correct number {correct:.3f}")
print(f"error {abs(glance - correct):.3f} -> {abs(cartoview - correct):.3f} m3; "
      f"{moved} cells changed region")
print(f"adjacent region pairs {len(k1)} -> {len(k2)}; lost "
      f"{len(k1 - k2)}, invented {len(k2 - k1)}")
```

```
region   old area  cartogram area  read share
north          36              42       28.8%
south          32              29       20.2%
east           15              25       17.4%
west           36              21       14.9%
central        25              27       18.8%
       222222222222
       222222222222
       222222222222
       222222111111
       111111111111
       111111111114
       444444444444
       444444444444
       000000000000
       000000000333
       333333333333
       333333333333
area-weighted read-back 18.484 -> 19.317 m3, correct number 19.713
error 1.228 -> 0.396 m3; 93 cells changed region
adjacent region pairs 8 -> 4; lost 6, invented 2
```

The correction is simple: every region is given a number of cells proportional to its read share. The
area of east goes from 15 up to 25, that of west from 36 down to 21. The read-back error falls from
**1.228 to 0.396 m³**, that is, to a third. The remaining 0.396 comes not from the area weight but
from the 2.179 m³ resolution of the color step, and cannot be closed by the area correction.

The price is written with two numbers. The first is that **93** of the 144 cells changed region; two
thirds of the grid is no longer where it started. The second is heavier: the original grid had **8**
pairs of neighboring regions, the cartogram is left with **4**. Six adjacencies were lost and two
adjacencies that do not exist were invented. The cartogram makes the correct number read back, but
spoils the answer to the question "which region is adjacent to which"; if the reader assumes they can
ask the second question from this display too, they fall into a new error.

The decision rule comes from here. A display built on an area cannot be reported without two
numbers: **the ratio of the region's area share to its read share** and **the resolution of the color
step**. If the area share is corrected, the number of pairs of adjacency lost is also written,
because the correction has made another question unreadable.

## Summary

- In a choropleth display the channel is the color step; five steps give a reading resolution of
  2.179 m³ and the read-back error per region is at most 1.090 m³.
- The number the reader reads back at a glance is weighted by area: the correct mean of 3,199
  readings is 19.713 m³, while the one read from the display comes out as 18.484 m³, a difference of
  -1.228 m³.
- The weight factor is the area share divided by the read share; west gets a factor of 1.680 and east
  0.598, and the display writes this weight nowhere.
- Projection stretching prints north and west, which are of equal area, 1.237 times different;
  that it reduces the total error by 0.095 m³ in this fictional setup is a coincidence, and its sign
  is not under the designer's control.
- When the area share is made equal to the read share, the error falls to 0.396 m³; the price is 93
  cells changing places and the pairs of neighboring regions dropping from 8 to 4.

## Next Step

None of the distortions measured in this lesson arose from a choice: the area is given on the grid,
and the stretching is the price of printing onto a flat surface. Even so, the number read back slipped
by 1.228 m³. The next lesson asks the same question from the opposite side and takes four distortions
as a **family** — axis truncation, the area channel enlarging the ratio by its square, the dual axis
and the uneven interval are applied to the same data in turn, and the read-back error of each is
printed separately. All four arise from a defensible default behavior; what is to be shown is that
misleading does not require intent.
