---
title: 'Dashboard Design'
source: 'https://academia.sh/en/courses/visualization-reporting-and-analysis-at-scale/dashboard-design'
course: 'Visualization, Reporting and Analysis at Scale'
language: en
updated: '2026-10-07T04:31:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines the three conditions a metric must meet to count as read back and measures each condition with a separate number'
    - 'Encodes the same twelve metrics in three layouts and prints two numbers side by side: the number of metrics read back and the number of supported decisions'
    - 'Calculates how much two read-back errors, each below the threshold on its own, grow in the difference between them'
---

# Dashboard Design

The same twelve metrics are encoded in three layouts, and the number of metrics read back at a glance is counted for each: 1 in the layout arranged by insertion order, 2 when every metric is given its own display, and 9 in the layout split by decision. The number of supported decisions is 0, 0 and 3 respectively; when the glance budget is raised to twelve, the first two layouts have 2 and 6 metrics read back but still support no decision. In the length channel, the 1.861 difference between two ratios read back separately with errors of 1.50% and 3.08% is read as 2.500, an error of 34.3%.

Across eight lessons, a single display was built correctly: a value was encoded into a channel, the
reader read it back, and the error in between was counted. A report is not made of a single
display. The person who will make the decision meets several numbers at a glance; every number may
be encoded correctly and the page may still support no decision at all.

The measure of this lesson is two numbers: **the number of metrics that can be read back at a
glance** and **the number of supported decisions**. The dashboard here is not an operations tool.
The dashboard lesson of the Observability and Operations course dealt with the on-call engineer's
screen and audited it by asking "does the panel answer a question"; alert rules and threshold
design were built there and **are not repeated here**. The question here is a different one: can
the number be read back from its channel, and does it have a comparison anchor that will turn it
into a decision.

- **RP1.** The dataset is inherited from the Data Collection and Preparation course and is **fictional**: a reading table of 3,199 rows,
  1,329 subscribers, three periods, five regions, tariff tiers of 10/25/40 m³. The seed is written
  into the code.
- **RP2.** The report is also **fictional**: the twelve metrics and three decisions come from the
  period-end report of the unit that runs the measurement network.
- **RP3.** A glance is **five fixations**. This is a decision; the same audit is run again with
  budgets of three, five, eight and twelve.
- **RP4.** The reading resolution is written into the decoder: a text bar counts as **whole
  cells** (20 cells), a color step resolves only to the **midpoint** of the step (5 steps), and a
  bare number is printed with two decimals. The channel carries the magnitude; the label carries
  the sign.
- **RP5.** The error threshold is **5%**; its rationale is that the smallest comparison in the
  report is of this magnitude. The scale ceiling is also a decision and is written together with
  the metric definition.
- **RP6.** A metric counts as **read back** if all three conditions are met at once: being within
  the glance budget, having an error below the threshold, having a comparison anchor.

## Metric, Channel and Anchor

All twelve metrics are computed from the fictional dataset. Three of them come from the Statistical Analysis and Exploratory Data Analysis course: the east
mean of 24.674 m³, its difference from the north of 4.463 m³, and the 69 subscribers with no
readings at all.

```python
# report.py — MODEL: the fictional dataset from K03 is generated with the same seed;
# a fictional period-end report is built on top of it. Each metric is encoded into a
# channel and the reader reads it back.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
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


SUBSCRIBERS, DISCARDED = [], []
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])
    (DISCARDED if r() < 0.046 else SUBSCRIBERS).append(
        {"subscriber_id": 10001 + i, "region": b[0], "base": b[2]})
VALID = {k["subscriber_id"] for k in SUBSCRIBERS}
READINGS = {}
for k in SUBSCRIBERS + DISCARDED:
    r = rng(SEED + 7000 + k["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(
            k["base"] * math.exp((r() + r() + r() - 1.5) * 0.62) * (1 - d * 0.05) * 100
            + 0.5) / 100
        if k["subscriber_id"] in VALID:
            READINGS.setdefault(k["subscriber_id"], {})[d + 1] = t
        r()


def readings_of(period, region=None):
    return [READINGS[k["subscriber_id"]][period] for k in SUBSCRIBERS
            if (region is None or k["region"] == region)
            and period in READINGS.get(k["subscriber_id"], {})]


def missing_rate(period, region=None):
    group = [k for k in SUBSCRIBERS if region is None or k["region"] == region]
    return sum(1 for k in group if period not in READINGS.get(k["subscriber_id"], {})) / len(group)


d2, d3 = readings_of(2), readings_of(3)
ordered = sorted(d3)
pooled = {b[0]: sum((readings_of(d, b[0]) for d in (1, 2, 3)), []) for b in REGION}
mean = {b: sum(v) / len(v) for b, v in pooled.items()}
missing = {b[0]: missing_rate(3, b[0]) for b in REGION}
most_missing = max(missing, key=lambda b: missing[b])

# (code, name, value, unit, scale ceiling). The ceiling is a decision; the channel encodes by it.
METRICS = [
    ("O1", "period 3 total consumption", sum(d3), "m3", 20000.0),
    ("O2", "mean per subscriber", sum(d3) / len(d3), "m3", 40.0),
    ("O3", "median consumption",
     (ordered[418] + ordered[419]) / 2, "m3", 40.0),
    ("O4", "missing reading rate", missing_rate(3) * 100, "%", 50.0),
    ("O5", "zero reading count", sum(1 for v in d3 if v == 0.0), "count", 50.0),
    ("O6", "subscriber share above 40 m3", sum(1 for v in d3 if v > 40) / len(d3) * 100, "%", 5.0),
    ("O7", "highest region mean", max(mean.values()), "m3", 40.0),
    ("O8", "east-north mean gap", mean["east"] - mean["north"], "m3", 10.0),
    ("O9", "period 2-3 change rate",
     (sum(d3) / len(d3) - sum(d2) / len(d2)) / (sum(d2) / len(d2)) * 100, "%", 20.0),
    ("O10", "subscriber share in 10-25 tier",
     sum(1 for v in d3 if 10 <= v < 25) / len(d3) * 100, "%", 100.0),
    ("O11", "subscribers with no readings",
     sum(1 for k in SUBSCRIBERS if not READINGS.get(k["subscriber_id"])), "count", 100.0),
    ("O12", "highest missing reading rate (" + most_missing + ")", missing[most_missing] * 100, "%", 50.0),
]
NAME = {o[0]: o[1] for o in METRICS}
VALUE = {o[0]: o[2] for o in METRICS}
UNIT = {o[0]: o[3] for o in METRICS}
CEILING = {o[0]: o[4] for o in METRICS}

print(f"fictional dataset (seed {SEED}): {len(SUBSCRIBERS)} subscribers, "
      f"{sum(len(v) for v in READINGS.values())} readings, {len(REGION)} regions")
print(f"report is built on period 3: {len(d3)} readings\n")
for k, name, d, unit, ceiling in METRICS:
    print(f"  {k:<4} {name:<38} {d:>10.3f} {unit}")
```

```
fictional dataset (seed 20260218): 1329 subscribers, 3199 readings, 5 regions
report is built on period 3: 838 readings

  O1   period 3 total consumption              15353.190 m3
  O2   mean per subscriber                        18.321 m3
  O3   median consumption                         17.845 m3
  O4   missing reading rate                       36.945 %
  O5   zero reading count                         28.000 count
  O6   subscriber share above 40 m3                0.955 %
  O7   highest region mean                        24.674 m3
  O8   east-north mean gap                         4.463 m3
  O9   period 2-3 change rate                     -6.910 %
  O10  subscriber share in 10-25 tier             74.105 %
  O11  subscribers with no readings               69.000 count
  O12  highest missing reading rate (west)        38.806 %
```

## Three Channels of the Same Number

The rationale for the threshold also comes out of this list: the smallest comparison the report
carries is between the dataset-wide missing reading rate and the rate of the highest region.

```python

# Each channel is an encoder and decoder pair; the reading resolution is written in the decoder.
CELLS, STEPS, THRESHOLD = 20, 5, 0.05


def encode(channel, x, ceiling):
    if channel == "number":
        return round(abs(x), 2)
    if channel == "length":
        return max(0, min(CELLS, int(abs(x) / ceiling * CELLS + 0.5)))
    return max(0, min(STEPS - 1, int(abs(x) / ceiling * STEPS)))


def decode(channel, code, ceiling):
    if channel == "number":
        return code
    if channel == "length":
        return code * ceiling / CELLS
    return (code + 0.5) * ceiling / STEPS


def readback_error(channel, k):
    x, ceiling = VALUE[k], CEILING[k]
    return abs(decode(channel, encode(channel, x, ceiling), ceiling) - abs(x)) / abs(x)


smallest = (VALUE["O12"] - VALUE["O4"]) / VALUE["O4"]
print(f"smallest comparison carried by the report: dataset-wide missing reading rate "
      f"{VALUE['O4']:.3f} versus\n{NAME['O12']} {VALUE['O12']:.3f}, a gap of "
      f"{smallest * 100:.1f} percent. The read-back error threshold is therefore {THRESHOLD * 100:.0f} percent.\n")
print(f"{'metric':<6}{'source value':>16}  {'channel':<9}{'encoded':>9}"
      f"{'read back':>13}{'error %':>9}")
for k in ("O9", "O12"):
    for channel in ("number", "length", "color"):
        print(f"{k:<6}{VALUE[k]:>16.3f}  {channel:<9}{encode(channel, VALUE[k], CEILING[k]):>9}"
              f"{decode(channel, encode(channel, VALUE[k], CEILING[k]), CEILING[k]):>13.3f}"
              f"{readback_error(channel, k) * 100:>9.2f}")
```

```
smallest comparison carried by the report: dataset-wide missing reading rate 36.945 versus
highest missing reading rate (west) 38.806, a gap of 5.0 percent. The read-back error threshold is therefore 5 percent.

metric    source value  channel    encoded    read back  error %
O9              -6.910  number        6.91        6.910     0.00
O9              -6.910  length           7        7.000     1.31
O9              -6.910  color            1        6.000    13.17
O12             38.806  number       38.81       38.810     0.01
O12             38.806  length          16       40.000     3.08
O12             38.806  color            3       35.000     9.81
```

The error of the bare number is close to zero, and that does not make it the best channel: whether
the 38.81 read without error is good or bad cannot be read from the source itself. The color step,
which divides the range into five buckets, exceeds the threshold on both metrics.

## Three Layouts, Two Numbers

The first layout keeps the insertion order of the metrics and prints all of them as bare numbers.
The second gives each metric a display and an anchor without changing the order. The third divides
them into sections by decision.

```python

# RP3: a glance is five fixations; the same audit runs below for four budgets.
BUDGET = 5
DECISION = [("which region gets an extra reading round", ("O4", "O12", "O8")),
            ("should the tariff tier boundary be reviewed", ("O3", "O6", "O10")),
            ("should the period 3 total be refreshed", ("O5", "O9", "O11"))]
ORDER = ["O1", "O2", "O9", "O4", "O5", "O3", "O6", "O7", "O8", "O10", "O11", "O12"]
KIND = {"O1": "length", "O2": "color", "O9": "color", "O4": "color", "O5": "length",
        "O3": "length", "O6": "color", "O7": "length", "O8": "number", "O10": "color",
        "O11": "length", "O12": "color"}
LAYOUT = {
    "insertion order": [[(k, "number", k in ("O8", "O9")) for k in ORDER]],
    "one display per metric": [[(k, KIND[k], True) for k in ORDER]],
    "split by decision": [[(k, "number" if k == "O8" else "length", True) for k in kk]
                          for _, kk in DECISION],
}


def audit(sections, budget=BUDGET):
    """A metric counts as read back only if three conditions hold at once:
    being within the glance budget, having an error below the threshold, having an anchor."""
    read_back = [k for b in sections for k, channel, anchor in b[:budget]
                 if anchor and readback_error(channel, k) <= THRESHOLD]
    return read_back, [name for name, kk in DECISION if all(k in read_back for k in kk)]


print(f"{len(METRICS)} metrics, {len(DECISION)} decisions, error threshold {THRESHOLD * 100:.0f} percent. "
      f"Last four columns: metrics read back / supported decisions.\n")
print(f"{'layout':<24}{'shown':>11}{'glances':>9}"
      + "".join(f"{'budget ' + str(b):>10}" for b in (3, 5, 8, 12)))
for name, sections in LAYOUT.items():
    print(f"{name:<24}{sum(len(b) for b in sections):>11}{len(sections):>9}" + "".join(
        f"{'/'.join(str(len(x)) for x in audit(sections, b)):>10}" for b in (3, 5, 8, 12)))
```

```
12 metrics, 3 decisions, error threshold 5 percent. Last four columns: metrics read back / supported decisions.

layout                        shown  glances  budget 3  budget 5  budget 8 budget 12
insertion order                  12        1       1/0       1/0       1/0       2/0
one display per metric           12        1       1/0       2/0       4/0       6/0
split by decision                 9        3       9/3       9/3       9/3       9/3
```

With the five-fixation budget the three layouts have 1, 2 and 9 metrics read back respectively;
the number of supported decisions is 0, 0 and 3.

The real information is in the last column. Even when the budget is raised to twelve, that is,
when the reader is assumed to scan the page from start to finish, the first two layouts support no
decision. What is missing is not space but **co-occurrence**: a decision requires three metrics to
be anchored and under the threshold at the same time, and in the first two layouts this triple is
not completed at any budget. A page that has six of the twelve metrics read back can support zero
decisions. The cost of the third layout is counted too: it shows nine metrics, leaves three out,
and asks for **three glances** instead of one.

## The Page the Reader Sees

```python

# What the reader sees is printed; "<" marks a metric that is read back.
def bar(k, channel):
    if channel == "length":
        n = encode("length", VALUE[k], CEILING[k])
        return "#" * n + "." * (CELLS - n)
    if channel == "color":
        return f"step {encode('color', VALUE[k], CEILING[k]) + 1}/{STEPS}".ljust(CELLS)
    return f"{VALUE[k]:.2f} {UNIT[k]}".ljust(CELLS)


def show(title, sections, names=None, cut=None):
    print(f"--- {title} " + "-" * (52 - len(title)))
    for i, b in enumerate(sections):
        if names:
            print(f"  [decision: {names[i]}]")
        for j, (k, channel, anchor) in enumerate(b[:cut] if cut else b):
            mark = " <" if j < BUDGET and anchor and readback_error(channel, k) <= THRESHOLD else ""
            print(f"  {j + 1:>2}. {NAME[k]:<36}{bar(k, channel)}"
                  f"{'  has anchor' if anchor else '  no anchor'}{mark}")


show("layout 1 — insertion order (five rows per glance)",
     LAYOUT["insertion order"], cut=BUDGET)
print()
show("layout 3 — split by decision", LAYOUT["split by decision"],
     [k[0] for k in DECISION])

actual = VALUE["O12"] - VALUE["O4"]
read = decode("length", encode("length", VALUE["O12"], CEILING["O12"]), CEILING["O12"]) - \
    decode("length", encode("length", VALUE["O4"], CEILING["O4"]), CEILING["O4"])
print(f"\nO4 error {readback_error('length', 'O4') * 100:.2f} percent, "
      f"O12 error {readback_error('length', 'O12') * 100:.2f} percent — both below the threshold.")
print(f"difference between them: source {actual:.3f}, read back {read:.3f}, "
      f"error of the difference {abs(read - actual) / actual * 100:.1f} percent")
```

```
--- layout 1 — insertion order (five rows per glance) ---
   1. period 3 total consumption          15353.19 m3           no anchor
   2. mean per subscriber                 18.32 m3              no anchor
   3. period 2-3 change rate              -6.91 %               has anchor <
   4. missing reading rate                36.95 %               no anchor
   5. zero reading count                  28.00 count           no anchor

--- layout 3 — split by decision ------------------------
  [decision: which region gets an extra reading round]
   1. missing reading rate                ###############.....  has anchor <
   2. highest missing reading rate (west) ################....  has anchor <
   3. east-north mean gap                 4.46 m3               has anchor <
  [decision: should the tariff tier boundary be reviewed]
   1. median consumption                  #########...........  has anchor <
   2. subscriber share above 40 m3        ####................  has anchor <
   3. subscriber share in 10-25 tier      ###############.....  has anchor <
  [decision: should the period 3 total be refreshed]
   1. zero reading count                  ###########.........  has anchor <
   2. period 2-3 change rate              #######.............  has anchor <
   3. subscribers with no readings        ##############......  has anchor <

O4 error 1.50 percent, O12 error 3.08 percent — both below the threshold.
difference between them: source 1.861, read back 2.500, error of the difference 34.3 percent
```

The last two lines show the gap the layout does not close. The two ratios in the first section are
read back in the length channel with errors of 1.50% and 3.08% respectively, both well below the
threshold. But the work the reader does in that section is not reading the individual values but
reading **the difference between them**. The difference, 1.861 points in the source, is read as
2.500: an error of 34.3%. Because the two small errors do not fall in the same direction, they
grow in the difference.

This shows not that the threshold was chosen wrongly but that the thing being measured has
changed. If a decision rests on a difference, the read-back error must be measured **on the
difference**: while the smallest comparison in the report is 5%, the 34.3% the channel produces in
the difference is large enough to overturn that comparison.

## Summary

- A metric counts as read back if all three conditions are met at once: being within the glance
  budget, having a read-back error below the threshold, having a comparison anchor.
- The same twelve metrics are read back 1, 2 and 9 times respectively in three layouts at a
  glance of five fixations; the number of supported decisions is 0, 0 and 3. When the budget is raised to
  twelve, the first two layouts have 2 and 6 metrics read back but still support no decision.
- The cost of the layout split by decision is counted: it shows nine metrics, leaves three out
  and asks for three glances instead of one. The bare number is read without error but, having no
  anchor, does not feed a decision.
- The 1.861 difference between two ratios read with errors of 1.50% and 3.08% individually is read
  as 2.500; the error of the difference is 34.3%.

## Next Step

This lesson took the metrics as given and measured only how they stand on the page. The metric
itself was not questioned: what the missing reading rate is a rate of, who is in its denominator,
at what moment a reading counts as a reading. From the moment a metric starts producing decisions,
it becomes possible to game it without moving what it measures. The next lesson counts this: in
how many ways the same missing reading rate can be gamed with nothing changing in the measurement
network, how much each way moves the metric, and how many of these ways close when the metric is
redefined.
