---
title: 'Misleading Visuals'
source: 'https://academia.sh/en/courses/visualization-reporting-and-analysis-at-scale/misleading-visuals'
course: 'Visualization, Reporting and Analysis at Scale'
language: en
updated: '2026-10-07T04:31:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Treats axis truncation, area enlargement, the dual axis and the uneven interval as members of a single family of distortions and prints the read-back error of each separately'
    - 'Computes that the area channel enlarges the ratio of values by its square and compares the value read back with the one in the source'
    - 'Shows that each of the four distortions arises from a defensible default behavior and that misleading does not require intent'
---

# Misleading Visuals

The true ratio of 1.2208 between east and north gives a bar ratio of 3.0769 when the axis is started from 18 m³, and a reader who takes length as proportional to value reads north as 8.019 m³ instead of 20.210. When the same ratio is encoded into a square, both sides are multiplied, so the area ratio comes out as 1.4905 and east is read as 30.123 m³. For two series with different units, nine defensible axis pairs give seven separate crossing periods. When 12 irregularly spaced periods are laid out at equal intervals, a slope of -0.2079 m³/period is read as -0.6635. All four arise from a defensible default behavior.

The previous lesson measured a distortion without tying it to any choice: the area of the regions 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³. This lesson widens the same question and treats four separate
distortions as a single **family**. The common feature of the family is this: every member arises from
the definition of the channel itself, none of them is a calculation error and none of them requires
intent.

How axis truncation distorts the ratio of bar lengths was measured in the basic charts lesson of the
Analysis with Spreadsheets course, and that number is not repeated here. There the measure was **the
legitimacy of the display** — is this chart acceptable at this scale. Here truncation is only one of
the four members of the family and the first of four operations applied in turn to the same data. What
is measured is the number each member makes the reader read back.

- **CD37. The distortion arises from the definition of the channel.** The length channel is read
  relative to the base, the area channel grows in two dimensions at once, the position channel depends
  on the axis span. None of them spoils the calculation, all three spoil the read-back.
- **CD38. The reader's decoder cannot be changed.** The reader takes length as proportional to value,
  area as proportional to value, and neighboring points as separated by an equal period interval. If
  the display falsifies this assumption, the error is written not to the display but to the reader.
- **CD39. The area channel enlarges the ratio by its square.** If both sides of a shape are multiplied
  to "show a ratio as larger", the area ratio becomes the square of the ratio of values.
- **CD40. A crossing on a dual axis has no counterpart in the data.** If the two series are in
  different units, the crossing period is determined by the choice of the two axis ranges, and
  although the choices are defensible they do not give a single number.
- **CD41. When an uneven interval is laid out equally, the slope is measured in the number of
  steps.** The reader takes a step as a period; the enlargement factor is the period span divided by
  the number of steps.
- **CD42. The series are fictional.** The consumption and reading count series of 36 periods were
  derived with the same seed, and that they are fictional is written in the code.

## Length: Axis Truncation

```python
# misleading.py — MODEL: the measurement network of K03-K05 is
# generated with the same seed. Both series of 36 periods are also FICTIONAL and
# were derived with the same seed: the consumption mean (m3) and the number of
# readings per period (count).
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]
VALUE = {a: sum(o["consumption_m3"] for o in READINGS if o["region"] == a)
         / sum(1 for o in READINGS if o["region"] == a) for a in NAMES}
N = 36
_g = rng(SEED + 505)
CONSUMPTION = [round(VALUE["north"] - 0.140 * t
                     + 2.20 * math.sin(2 * math.pi * t / 12)
                     + (_g() + _g() + _g() - 1.5) * 0.60, 3) for t in range(N)]
_h = rng(SEED + 917)
READ_COUNT = [round(1066 + 6.4 * t + 40 * math.sin(2 * math.pi * t / 12)
                    + (_h() + _h() + _h() - 1.5) * 26) for t in range(N)]

WIDTH = 40


def length(v, base, top):
    """Encoder: value -> length. The bar is rounded to a whole character."""
    return max(0, round(WIDTH * (v - base) / (top - base)))


def decode_length(u, base, top):
    """Decoder: length -> value. The reading resolution is one character."""
    return base + u / WIDTH * (top - base)


a, b = VALUE["east"], VALUE["north"]
TRUE_RATIO = a / b
print(f"east {a:.3f} m3, north {b:.3f} m3, true ratio {TRUE_RATIO:.4f}")
for base in (0.0, 18.0):
    ua, ub = length(a, base, a), length(b, base, a)
    print(f"axis from {base:4.0f} m3: east  |{'#' * ua}")
    print(f"                   north |{'#' * ub}")
    print(f"  bar ratio {ua / ub:.4f}; a reader who takes length as proportional "
          f"to value reads north as {decode_length(ub, 0.0, a):.3f} m3")
TRUNCATED = length(a, 18.0, a) / length(b, 18.0, a)
print(f"truncation ratio {TRUE_RATIO:.4f} -> {TRUNCATED:.4f}, enlargement factor "
      f"{TRUNCATED / TRUE_RATIO:.3f}; read-back error at north "
      f"{decode_length(length(b, 18.0, a), 0.0, a) - b:+.3f} m3")
```

```
east 24.674 m3, north 20.210 m3, true ratio 1.2208
axis from    0 m3: east  |########################################
                   north |#################################
  bar ratio 1.2121; a reader who takes length as proportional to value reads north as 20.356 m3
axis from   18 m3: east  |########################################
                   north |#############
  bar ratio 3.0769; a reader who takes length as proportional to value reads north as 8.019 m3
truncation ratio 1.2208 -> 3.0769, enlargement factor 2.520; read-back error at north -12.191 m3
```

The two sets of bars carry the same two numbers. On the axis that starts from zero the ratio comes out
as 1.2121; the true ratio is 1.2208, and the difference comes from the bars being rounded to whole
characters, that is, from the reading resolution. On the axis that starts from 18 m³ the ratio of the
same two numbers becomes **3.0769**.

The real number is on the last line. A reader who takes length as proportional to value reads north
from the truncated display as **8.019 m³**; the true value is 20.210 and the read-back error is
**-12.191 m³**. Neither did the data change nor was a calculation done wrongly.

Where this decision comes from matters. The means of the two regions are squeezed between 20.210 and
24.674, and on an axis that starts from zero the two bars look almost the same. "Make the difference
visible" is a design requirement, and the previous course had treated it as a legitimate decision —
since what is read on a line chart is the slope, the axis may be truncated there. When the same
decision is applied to the length channel, it falsifies the reader's decoder. The decision is the
same, the channel is different, and the result is an error of 12 m³.

## Area: The Square of the Ratio

```python
# area.py — the decision to "show it twice as large" enlarges the shape in two
# dimensions at once; the reader reads the area and the ratio returns squared.
SIDE = 12


def encode_area(ratio):
    """Encoder: ratio -> area. Both sides are multiplied by the ratio."""
    k = SIDE * ratio
    return k * k


def decode_area(ar):
    """Decoder: area -> value. The reader takes the area as proportional to the value."""
    return b * ar / (SIDE * SIDE)


area_a, area_b = encode_area(TRUE_RATIO), encode_area(1.0)
print(f"north square {SIDE}x{SIDE} = {area_b:.0f} area units")
print(f"east square {SIDE * TRUE_RATIO:.2f}x{SIDE * TRUE_RATIO:.2f} = {area_a:.1f} area units")
print(f"area ratio {area_a / area_b:.4f} = square of the true ratio {TRUE_RATIO ** 2:.4f}")
print(f"east value read back {decode_area(area_a):.3f} m3, true {a:.3f} m3, "
      f"error {decode_area(area_a) - a:+.3f} m3")
```

```
north square 12x12 = 144 area units
east square 14.65x14.65 = 214.6 area units
area ratio 1.4905 = square of the true ratio 1.4905
east value read back 30.123 m3, true 24.674 m3, error +5.449 m3
```

The second member of the family does not touch the axis at all. To show that east is 1.2208 times
larger than north, the square of east is scaled by 1.2208; the scaling is applied to both sides at
once because the square must remain a square. The result is that the area ratio is **1.4905**, and
this number is the square of the ratio of values.

The reader sees the area, not the side. A reader who takes area as proportional to value reads east
as **30.123 m³**; the true value is 24.674 and the read-back error is **+5.449 m³**. The enlargement
factor is 1.2208, that is, as large as the ratio itself — the larger the ratio, the larger the
distortion.

This member differs from axis truncation in two respects. First, it cannot be corrected: making the
ratio of the sides the square root of the value corrects the area, but then the ratio of the sides
no longer belongs to the data, and someone who reads the side is mistaken. Second, the decision
itself is invisible; the axis base is a number and can be written down, but "which dimension was
scaled" stands nowhere in the display.

## Position: Dual Axis and Uneven Interval

```python
# dual.py — two series in two separate units. When each axis is stretched to
# its own range, the crossing period is determined by the choice of axis.
ROWS = 12


def position(v, low, high):
    return max(0, min(ROWS - 1, round((v - low) / (high - low) * (ROWS - 1))))


def options(v):
    lo, hi = min(v), max(v)
    return {"from zero": (0.0, hi),
            "data range": (math.floor(lo), math.ceil(hi)),
            "wide": (math.floor(lo) - (hi - lo) * 0.5, math.ceil(hi))}


oc, on = options(CONSUMPTION), options(READ_COUNT)
print(f"{'consumption axis':18s}{'count axis':15s}{'crossing period':>15s}")
crossing = set()
for na, (a_lo, a_hi) in oc.items():
    for nb, (b_lo, b_hi) in on.items():
        k = next((t for t in range(N)
                  if position(CONSUMPTION[t], a_lo, a_hi) <= position(READ_COUNT[t], b_lo, b_hi)), None)
        crossing.add(k)
        print(f"{na:16s}  {nb:14s} {str(k):>15s}")
print(f"{len(oc) * len(on)} defensible axis pairs give "
      f"{len(crossing)} different crossing periods")
print(f"the units of the two series differ (m3 and count); the crossing period is "
      f"a number with no counterpart in the data")
```

```
consumption axis  count axis     crossing period
from zero         from zero                    5
from zero         data range                  25
from zero         wide                        19
data range        from zero                    0
data range        data range                  18
data range        wide                         8
wide              from zero                    0
wide              data range                  19
wide              wide                        17
9 defensible axis pairs give 7 different crossing periods
the units of the two series differ (m3 and count); the crossing period is a number with no counterpart in the data
```

The dual axis is the hardest member of the family to defend, because its reason for existing is
legitimate: the consumption mean is in m³, the number of readings per period is in count, and putting
the two on a single axis flattens one series into a straight line. Stretching each series to its own
range is the natural solution.

The price is in the table. Nine defensible axis pairs give **seven separate crossing periods**, and
among them are completely different answers such as period 0 and period 25. The sentence "in this
period one overtook the other" that the reader draws from this display corresponds to nothing in the
data; the impression that two different units are comparable is entirely a product of the axis
choice. In the other three members the number read back had an error; here the number read back
**has no source**.

```python
# interval.py — if the periods on hand are laid out at equal intervals, the
# slope is spoiled.
HELD = [0, 1, 2, 3, 4, 5, 6, 12, 18, 24, 30, 35]


def slope(xs, ys):
    n = len(xs)
    mx, my = sum(xs) / n, sum(ys) / n
    return (sum((xs[i] - mx) * (ys[i] - my) for i in range(n))
            / sum((x - mx) ** 2 for x in xs))


y = [CONSUMPTION[t] for t in HELD]
gaps = [HELD[i + 1] - HELD[i] for i in range(len(HELD) - 1)]
correct = slope(HELD, y)
drawn = slope(list(range(len(HELD))), y)
print(f"{len(HELD)} periods on hand, spread from period {HELD[0]} to period "
      f"{HELD[-1]}; gaps {min(gaps)}-{max(gaps)} periods")
print(f"true slope {correct:+.4f} m3/period ({HELD[-1] - HELD[0]} periods of spread)")
print(f"slope read from the display drawn at equal intervals {drawn:+.4f} "
      f"m3/period ({len(HELD) - 1} steps), enlargement {drawn / correct:.2f} times")
print(f"{'step':>4s}{'true gap':>15s}{'true slope':>13s}{'drawn slope':>14s}")
for i in (0, 6, 7, len(gaps) - 1):
    print(f"{i:4d} {gaps[i]:14d} {(y[i + 1] - y[i]) / gaps[i]:+12.4f} "
          f"{y[i + 1] - y[i]:+13.4f}")

TABLE = [("axis truncation", "length", f"{TRUE_RATIO:.4f}", f"{TRUNCATED:.4f}",
          f"{TRUNCATED / TRUE_RATIO:.2f}x", "axis base"),
         ("area enlargement", "area", f"{TRUE_RATIO:.4f}", f"{area_a / area_b:.4f}",
          f"{(area_a / area_b) / TRUE_RATIO:.2f}x", "whether side or area was scaled"),
         ("dual axis", "position", "none", f"{len(crossing)} separate periods",
          "-", "range of the two axes"),
         ("uneven interval", "position", f"{correct:.4f}", f"{drawn:.4f}",
          f"{drawn / correct:.2f}x", "period gaps")]
print(f"{'distortion':18s} {'channel':8s} {'source':>8s} {'read back':>18s} {'growth':>9s}   unwritten decision")
for name, channel, src, back, grow, row in TABLE:
    print(f"{name:18s} {channel:8s} {src:>8s} {back:>18s} {grow:>9s}   {row}")
```

```
12 periods on hand, spread from period 0 to period 35; gaps 1-6 periods
true slope -0.2079 m3/period (35 periods of spread)
slope read from the display drawn at equal intervals -0.6635 m3/period (11 steps), enlargement 3.19 times
step       true gap   true slope   drawn slope
   0              1      +1.3370       +1.3370
   6              6      -0.1895       -1.1370
   7              6      -0.0655       -0.3930
  10              5      -0.3822       -1.9110
distortion         channel    source          read back    growth   unwritten decision
axis truncation    length     1.2208             3.0769     2.52x   axis base
area enlargement   area       1.2208             1.4905     1.22x   whether side or area was scaled
dual axis          position     none 7 separate periods         -   range of the two axes
uneven interval    position  -0.2079            -0.6635     3.19x   period gaps
```

The fourth member is the one that appears most often and most unnoticed. There are 12 periods on hand
and they are spread from period 0 to period 35; the gaps vary between 1 and 6 periods. The display
lays them out in order and at equal intervals, because laying out what is on hand in order is the
default behavior.

The slope is read as **-0.6635** instead of -0.2079 m³/period, **3.19 times** larger. The factor is
close to the period span divided by the number of steps: 35 divided by 11, that is, 3.18. The step
table shows how this comes about step by step — at step 0 the true gap is one period and the two
numbers coincide, at step 6 the gap rises to six periods and the drop of -0.1895 per period is read
in the display as -1.1370.

The last table puts the four members side by side. Its last column is what the four have in common:
**in each of them, the information that would correct the read-back is left outside the display**. The
axis base is a number and can be written down; so can the period gaps. "Which dimension was scaled"
and "what is the range of the two axes" are known only where the display is made and never reach the
reader. All four arose from a defensible default: make the difference visible, enlarge the shape by
the ratio, stretch the two series to their own ranges, lay out what is on hand in order. Misleading
required no intent in any of them.

## Summary

- The four distortions are a single family: they arise from the definition of the channel itself, do
  not spoil the calculation, and spoil the read-back.
- When the axis is started from 18 m³, the ratio of 1.2208 is read as 3.0769, and a reader who takes
  length as proportional to value reads north as 8.019 m³ instead of 20.210, an error of -12.191 m³.
- When the same ratio is encoded into a square, both sides are multiplied at once, so the area ratio
  is 1.4905, that is, the square of the ratio; east is read as 30.123 m³ and the error is +5.449 m³.
- In two series with different units, nine defensible axis pairs give seven separate crossing
  periods; the number read back has no counterpart in the source.
- When 12 irregularly spaced periods are laid out equally, a slope of -0.2079 m³/period is read as
  -0.6635; the factor is close to the period span divided by the number of steps, 35 divided by 11,
  that is, 3.18.
- In all four, the information that would correct the read-back is left outside the display, and all
  four arise from a defensible default behavior.

## Next Step

This lesson showed that distortion does not require intent; in every member the display had been
built correctly and not a single number had been calculated wrongly. One assumption still stands
untouched: that the reader is able to read the channel. The next lesson measures this. How many series
remain indistinguishable from one another when color is the only encoding channel is counted, and how
many this number falls to with **redundant encoding**, made up of shape, direct labeling and ordering,
is measured. The contrast ratio calculation and the color system are the business of the interface
courses and are not repeated; what is counted here is only what is specific to the data.
