|
| 1 | +--- |
| 2 | +title: "Data Types <> Scales <> Marks" |
| 3 | +toc: true |
| 4 | +--- |
| 5 | + |
| 6 | +# Data Types <> Scales <> Marks |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +This lesson ties three ideas together: the **kinds of values** in your data, the **scales** Plot uses to turn those values into positions and colors, and the **marks** you choose to draw. Marks are specified in [data space](https://observablehq.com/plot/features/marks) (times, categories, measurements)—not in raw pixels—and scales bridge data space and the chart. |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## Data Types |
| 15 | + |
| 16 | +We can group fields in a dataset into broad families. The type of data hints at which scale Plot should use and which mark channels make sense. |
| 17 | + |
| 18 | +### Qualitative (non-numeric, descriptive) |
| 19 | + |
| 20 | +- **Nominal** — Distinct categories _without_ a meaningful order (e.g. hair color, marital status, island name). |
| 21 | +- **Ordinal** — Distinct categories _with_ a meaningful order, but the gaps between levels are not a fixed numeric distance (e.g. letter grades, satisfaction “low / medium / high”). |
| 22 | + |
| 23 | +### Quantitative (numeric, measurable) |
| 24 | + |
| 25 | +- **Discrete** — Countable, often _whole numbers_ from counting (e.g. number of children, number of events). |
| 26 | +- **Continuous** — Can take _any value_ in a range, including fractions, from measuring (e.g. height, weight, temperature). |
| 27 | + |
| 28 | +The same real-world idea can be stored in different ways. **Weight** (continuous) is not the same as **weight class** (“light / medium / heavy”), which is ordinal or nominal. If you bin continuous measurements (e.g. with `Plot.bin()`), you can move data from many numeric values to fewer ordered or categorical bins—a pattern that pairs well with bar-style marks. |
| 29 | + |
| 30 | +_Note: see [transforms](./6_transforms_and_data_manipulation.md) for how binning changes cardinality._ |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## Scales |
| 35 | + |
| 36 | +From the [marks documentation](https://observablehq.com/plot/features/marks): marks are (typically) not positioned in literal pixel coordinates. You supply **abstract values** (time, temperature, category labels, counts). Marks are drawn in **data space** -- and **scales** are used to encode those values into visual results—horizontal and vertical position, color, radius, and so on. Plot also builds axes and legends that explain those encodings. |
| 37 | + |
| 38 | +Think of a simple line chart: `x` might encode time, `y` might encode price—both abstract. The x scale maps the time domain to horizontal pixel extent; the y scale maps the price domain to vertical extent. |
| 39 | + |
| 40 | +A faceted or small-multiple layout is the same idea with more channels: e.g. `x` = island (category), `y` = count (number), `fill` = island (category) mapped through a color scale to distinct hues. The domain is what goes in (your data values); the range is what comes out (pixel span, color ramp, etc.). |
| 41 | + |
| 42 | +Plot’s [scales guide](https://observablehq.com/plot/features/scales) spells this out formally: |
| 43 | + |
| 44 | +- **Domain**: The input values the scale expects. For quantitative or temporal data, this is often an extent such as min–max. For ordinal or nominal data, it is often an explicit list of categories. The domain lines up with options like linear vs ordinal scale behavior. |
| 45 | +- **Range**: The output of the scale. For position scales, typically left–right or bottom–top in pixel space. For color scales, a continuous ramp (e.g. blue → red) or a list of discrete colors. |
| 46 | + |
| 47 | +You rarely set every scale by hand; Plot infers sensible defaults from the types of the values bound to each channel. |
| 48 | + |
| 49 | +### Continuous position scales |
| 50 | + |
| 51 | +You can set **`x`** or **`y`** **`domain`** yourself. For numeric data, the domain is usually an extent `[low, high]`. Swap the ends of the extent to reverse the axis. |
| 52 | + |
| 53 | +```js echo |
| 54 | +display(Plot.plot({ |
| 55 | + x: { domain: [0, 100], grid: true } |
| 56 | +})); |
| 57 | +``` |
| 58 | + |
| 59 | +```js echo |
| 60 | +display(Plot.plot({ |
| 61 | + x: { domain: [100, 0], grid: true } |
| 62 | +})); |
| 63 | +``` |
| 64 | + |
| 65 | +Widen the plot by setting **`width`** (use the reactive `width` your page provides, or a fixed number such as `640`). |
| 66 | + |
| 67 | +```js echo |
| 68 | +display(Plot.plot({ |
| 69 | + width: 640, |
| 70 | + x: { domain: [0, 100], grid: true } |
| 71 | +})); |
| 72 | +``` |
| 73 | + |
| 74 | +When values are **dates**, the domain can be date extents. Month indices in `Date` are 0-based, so January is `0`. |
| 75 | + |
| 76 | +```js echo |
| 77 | +display(Plot.plot({ |
| 78 | + x: { |
| 79 | + domain: [new Date(2025, 0, 1), new Date()], |
| 80 | + grid: true |
| 81 | + } |
| 82 | +})); |
| 83 | +``` |
| 84 | + |
| 85 | +### Discrete position scales: point and band |
| 86 | + |
| 87 | +For categorical **x** or **y**, Plot often uses a **point** scale: categories land on evenly spaced positions (a common default for ordinal data on position). |
| 88 | + |
| 89 | +```js echo |
| 90 | +const letters = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]; |
| 91 | +display(Plot.plot({ x: { type: "point", domain: letters, grid: true } })); |
| 92 | +``` |
| 93 | + |
| 94 | +A **band** scale splits space into evenly spaced **intervals** (bands). That is what bar charts rely on—each category owns a band. **`Plot.cell`** makes those bands visible: |
| 95 | + |
| 96 | +```js echo |
| 97 | +const letters = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]; |
| 98 | +display(Plot.plot({ |
| 99 | + x: { type: "band", domain: letters }, |
| 100 | + marks: [ |
| 101 | + Plot.cell(letters, { |
| 102 | + x: (d) => d, |
| 103 | + stroke: "lightgrey" |
| 104 | + }) |
| 105 | + ] |
| 106 | +})); |
| 107 | +``` |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## Diamonds: data types, scales, and marks |
| 112 | + |
| 113 | +The built-in **`diamonds`** dataset (available in Observable Framework with other sample tables) mixes **continuous** fields—`carat`, `depth`, `price`, `x`, `y`, and so on—with **ordinal** fields such as **`cut`** and **`color`**. Skim the columns: |
| 114 | + |
| 115 | +```js echo |
| 116 | +view(Inputs.table(diamonds.slice(0, 100))); |
| 117 | +``` |
| 118 | + |
| 119 | +**Two continuous position channels** pair naturally with **`Plot.dot`** (here `x` and `y` are spatial fields in the dataset, and **radius** encodes **price**): |
| 120 | + |
| 121 | +```js echo |
| 122 | +display(Plot.plot({ |
| 123 | + marks: [ |
| 124 | + Plot.dot(diamonds, { |
| 125 | + x: "x", |
| 126 | + y: "y", |
| 127 | + r: "price", |
| 128 | + fill: "currentColor", |
| 129 | + opacity: 0.25, |
| 130 | + tip: true |
| 131 | + }) |
| 132 | + ] |
| 133 | +})); |
| 134 | +``` |
| 135 | + |
| 136 | +**One ordinal position + one quantitative length** is the usual bar pattern. **`Plot.groupX`** counts rows per **`cut`**: |
| 137 | + |
| 138 | +```js echo |
| 139 | +display(Plot.plot({ |
| 140 | + marks: [ |
| 141 | + Plot.barY( |
| 142 | + diamonds, |
| 143 | + Plot.groupX({ y: "count" }, { x: "cut", y: "count" }) |
| 144 | + ) |
| 145 | + ] |
| 146 | +})); |
| 147 | +``` |
| 148 | + |
| 149 | +You can add another summary on the same grouping—for example **mean price** driving **`fill`**: |
| 150 | + |
| 151 | +```js echo |
| 152 | +display(Plot.plot({ |
| 153 | + marks: [ |
| 154 | + Plot.barY( |
| 155 | + diamonds, |
| 156 | + Plot.groupX( |
| 157 | + { y: "count", fill: "mean" }, |
| 158 | + { x: "cut", y: "count", fill: "price", tip: true } |
| 159 | + ) |
| 160 | + ) |
| 161 | + ], |
| 162 | + color: { |
| 163 | + scheme: "YlOrRd", |
| 164 | + legend: true, |
| 165 | + label: "Average price" |
| 166 | + }, |
| 167 | + x: { label: "Cut" }, |
| 168 | + y: { label: "Count of diamonds" } |
| 169 | +})); |
| 170 | +``` |
| 171 | + |
| 172 | +**Two ordinal position channels** often use **`Plot.cell`**. With one row per diamond, raw cells overlap; the snippet below uses the first 100 rows like the [Fall 2025 week 7 notes](https://raw.githubusercontent.com/InteractiveDataVis/Interactive-Data-Vis-Fall2025/refs/heads/class/src/lab_2/week_7_notes.md) to keep the grid readable: |
| 173 | + |
| 174 | +```js echo |
| 175 | +display(Plot.plot({ |
| 176 | + marks: [ |
| 177 | + Plot.frame(), |
| 178 | + Plot.cell(diamonds.filter((d, i) => i < 100), { |
| 179 | + x: "color", |
| 180 | + y: "cut", |
| 181 | + opacity: 0.25 |
| 182 | + }) |
| 183 | + ] |
| 184 | +})); |
| 185 | +``` |
| 186 | + |
| 187 | +**`Plot.text`** with **`Plot.group`** can show **counts** per `(cut, color)` pair: |
| 188 | + |
| 189 | +```js echo |
| 190 | +display(Plot.plot({ |
| 191 | + marks: [ |
| 192 | + Plot.frame(), |
| 193 | + Plot.text( |
| 194 | + diamonds, |
| 195 | + Plot.group({ text: "count" }, { x: "cut", y: "color", text: "count" }) |
| 196 | + ) |
| 197 | + ] |
| 198 | +})); |
| 199 | +``` |
| 200 | + |
| 201 | +Aggregating **mean price** into each cell yields a heatmap-style view: |
| 202 | + |
| 203 | +```js echo |
| 204 | +display(Plot.plot({ |
| 205 | + marks: [ |
| 206 | + Plot.frame(), |
| 207 | + Plot.cell( |
| 208 | + diamonds, |
| 209 | + Plot.group( |
| 210 | + { fill: "mean" }, |
| 211 | + { x: "color", y: "cut", fill: "price", tip: true } |
| 212 | + ) |
| 213 | + ) |
| 214 | + ], |
| 215 | + color: { |
| 216 | + scheme: "YlOrRd", |
| 217 | + legend: true, |
| 218 | + label: "Average price", |
| 219 | + tickFormat: d3.format("$,.0f") |
| 220 | + } |
| 221 | +})); |
| 222 | +``` |
| 223 | + |
| 224 | +--- |
| 225 | + |
| 226 | +## Data Types <> Marks |
| 227 | + |
| 228 | +There is no single rule for every chart, but usually each mark expects certain kinds of channels. The table below matches the Week 8 slides: typical `x` and `y` data types for common marks, plus extra channels where the slides call them out. |
| 229 | + |
| 230 | +| Mark | x (typical) | y (typical) | Other channels | |
| 231 | +|------|-------------|-------------|------------------------------| |
| 232 | +| `Plot.lineY()` / `Plot.lineX()` | quantitative | quantitative | (orientation depends on which line mark you use) | |
| 233 | +| `Plot.barY()` | ordinal (or nominal) | quantitative | Categories on `x`, lengths on `y` | |
| 234 | +| `Plot.barX()` | quantitative | ordinal (or nominal) | Lengths on `x`, categories on `y` | |
| 235 | +| `Plot.dot()` | quantitative | quantitative | **r** (radius) is often quantitative | |
| 236 | +| `Plot.cell()` | ordinal (or nominal) | ordinal (or nominal) | **fill** can be quantitative _or_ qualitative | |
| 237 | + |
| 238 | +Bars often pair a categorical position channel with a numeric length. Dots in a scatterplot usually put two quantitative channels on `x` and `y`. Cells (heatmap-style) commonly use two categorical axes to index a grid, with fill carrying the value you want to emphasize. |
| 239 | + |
| 240 | +In code, you still pass field names (or arrays); Plot looks at the values (numbers, dates, strings) to choose scale behavior. If something looks wrong, ask: “Is this channel ordered numbers, dates, or labels?”—that answer points to the right scale and mark pairing. |
| 241 | + |
| 242 | +```js echo |
| 243 | +const rows = [ |
| 244 | + { region: "North", sales: 120 }, |
| 245 | + { region: "South", sales: 90 }, |
| 246 | + { region: "East", sales: 150 } |
| 247 | +]; |
| 248 | + |
| 249 | +display(Plot.plot({ |
| 250 | + marginLeft: 50, |
| 251 | + marks: [ |
| 252 | + Plot.barY(rows, { x: "region", y: "sales", fill: "steelblue" }) |
| 253 | + ] |
| 254 | +})) |
| 255 | +``` |
| 256 | + |
| 257 | +Here `x` is nominal/ordinal (region labels) and `y` is quantitative (sales)—the usual `Plot.barY` pattern from the table. |
| 258 | + |
| 259 | +--- |
| 260 | + |
| 261 | +## Color Scales |
| 262 | + |
| 263 | +**Color** is another channel, and it has its own scale. Color scale “families” mirror the data-type ideas above. See [Plot: scales](https://observablehq.com/plot/features/scales) for full options. |
| 264 | + |
| 265 | +1. **Categorical (qualitative)** |
| 266 | + - Nominal-style — Distinct categories → distinct hues (e.g. species, island). |
| 267 | + - Ordinal-style — Still discrete, but order matters (e.g. grades, satisfaction bands); choose a palette that respects order. |
| 268 | + |
| 269 | +```js |
| 270 | +Plot.plot({ |
| 271 | + color: { |
| 272 | + type: "ordinal", |
| 273 | + scheme: "Category10" |
| 274 | + }, |
| 275 | + marks: [ |
| 276 | + Plot.cell("ABCDEFGHIJ", {x: Plot.identity, fill: Plot.identity}) |
| 277 | + ] |
| 278 | +}) |
| 279 | +``` |
| 280 | + |
| 281 | +2. **Quantitative** |
| 282 | + - Linear (and related) — Continuous numeric values interpolated along a ramp (e.g. temperature, population). Plot also supports transforms such as sqrt, log, power, and symlog on quantitative color scales when compression of large ranges helps. |
| 283 | + |
| 284 | +```js |
| 285 | +Plot.plot({ |
| 286 | + axis: null, |
| 287 | + padding: 0, |
| 288 | + color: { |
| 289 | + type: "linear", |
| 290 | + scheme: "blues" |
| 291 | + }, |
| 292 | + marks: [ |
| 293 | + Plot.cell(d3.range(40), {x: Plot.identity, fill: Plot.identity, inset: -0.5}) |
| 294 | + ] |
| 295 | +}) |
| 296 | +``` |
| 297 | + |
| 298 | +3. **Diverging** |
| 299 | + - Emphasizes a midpoint (often zero) and moves to two different hues on either side—useful for positive vs negative or above/below a reference. |
| 300 | + |
| 301 | +```js |
| 302 | +Plot.plot({ |
| 303 | + axis: null, |
| 304 | + padding: 0, |
| 305 | + color: { |
| 306 | + type: "linear", |
| 307 | + scheme: "brbg" |
| 308 | + }, |
| 309 | + marks: [ |
| 310 | + Plot.cell(d3.range(40), {x: Plot.identity, fill: Plot.identity, inset: -0.5}) |
| 311 | + ] |
| 312 | +}) |
| 313 | +``` |
| 314 | + |
| 315 | +4. **More** — Sequential, threshold, quantile, and other specialized color scales appear in the docs when you need them. |
| 316 | + |
| 317 | +You configure the plot-level color scale with the top-level **`color`** option (e.g. `legend`, `scheme`, `domain`, `type`), while individual marks still say which field drives color via **`fill`** or **`stroke`**. |
| 318 | + |
| 319 | +```js echo |
| 320 | +const penguins = [ |
| 321 | + { island: "Biscoe", bill: 48 }, |
| 322 | + { island: "Dream", bill: 44 }, |
| 323 | + { island: "Torgersen", bill: 46 } |
| 324 | +]; |
| 325 | + |
| 326 | +display(Plot.plot({ |
| 327 | + marginLeft: 80, |
| 328 | + marginBottom: 40, |
| 329 | + marks: [ |
| 330 | + Plot.dot(penguins, { x: "bill", y: "island", fill: "island", r: 8 }) |
| 331 | + ], |
| 332 | + color: { legend: true } |
| 333 | +})) |
| 334 | +``` |
| 335 | + |
| 336 | +**Fill** binds **island** (qualitative) to the mark; **`color: { legend: true }`** documents the categorical color scale at the plot level. |
0 commit comments