|
36 | 36 | * `minContrastRatio`, or when `colorBlindSafe` is set |
37 | 37 | * and the categorical palette collapses under |
38 | 38 | * deuteranopia simulation. |
39 | | - * |
40 | | - * Reserved (planned for a follow-up; not yet implemented): |
41 | | - * AUDIT-05 — Time axis with gaps. Needs a temporal-axis schema check |
42 | | - * that the audit module doesn't have full coverage for yet. |
| 39 | + * AUDIT-05 (medium) Line / area mark with a CATEGORICAL x-encoding. |
| 40 | + * The connecting line implies an ordered progression |
| 41 | + * that nominal categories don't carry — viewers |
| 42 | + * read a fake trend. |
| 43 | + * AUDIT-12 (medium) Pie / donut / arc with too many slices (>7). Angle |
| 44 | + * comparison drops sharply past ~5 slices (Cleveland- |
| 45 | + * McGill); past 7 is essentially unreadable. |
| 46 | + * AUDIT-13 (low) Bar chart with a quantitative x. Bars on a |
| 47 | + * continuous axis usually want `mark: "rect"` or |
| 48 | + * binned histogram semantics; otherwise the chart |
| 49 | + * conflates ordinal grouping with continuous space. |
| 50 | + * AUDIT-14 (low) More than 4 overlay layers in one chart. Visual |
| 51 | + * overload past 4 layers makes individual series |
| 52 | + * hard to follow; small multiples or faceting reads |
| 53 | + * better. |
| 54 | + * AUDIT-15 (low) Multi-layer chart with no title. A bare multi- |
| 55 | + * series chart is unreadable without context; title |
| 56 | + * anchors what the reader is comparing. |
43 | 57 | * |
44 | 58 | * |
45 | 59 | * Deterministic, no clock, no LLM. Each rule lives in its own function so |
@@ -87,13 +101,18 @@ export function auditSpec(input: AuditInput): ReadonlyArray<AuditFinding> { |
87 | 101 | auditTruncatedYAxis(out, layer, i); |
88 | 102 | auditLogScaleDisclosure(out, layer, i, spec.title); |
89 | 103 | auditDivergingPalette(out, layer, i); |
| 104 | + auditLineOnCategoricalX(out, layer, i); |
| 105 | + auditBarOnQuantitativeX(out, layer, i); |
90 | 106 | } |
91 | 107 | auditDualAxis(out, spec); |
92 | 108 | auditExcessiveAggregation(out, spec, input.rowCount); |
93 | 109 | auditColorCount(out, input.colorCardinality); |
94 | 110 | auditAspectRatio(out, spec); |
95 | 111 | auditStackedNegatives(out, spec); |
96 | 112 | auditBrandContrast(out, spec); |
| 113 | + auditTooManyArcSlices(out, spec, input.rowCount); |
| 114 | + auditOverlayLayerCount(out, spec); |
| 115 | + auditMissingTitle(out, spec); |
97 | 116 | return out.sort((a, b) => { |
98 | 117 | const sa = severityRank(a.severity); |
99 | 118 | const sb = severityRank(b.severity); |
@@ -348,6 +367,129 @@ function auditBrandContrast(out: AuditFinding[], spec: GlyphSpec): void { |
348 | 367 | // Helpers |
349 | 368 | // --------------------------------------------------------------------------- |
350 | 369 |
|
| 370 | +// --------------------------------------------------------------------------- |
| 371 | +// Rule: AUDIT-05 — line / area mark on a categorical x |
| 372 | +// --------------------------------------------------------------------------- |
| 373 | +// A connecting line implies ordered progression. Nominal categories like |
| 374 | +// "Engineering / Sales / Marketing" don't carry that order, so the slope |
| 375 | +// between two adjacent categories is artifactual — viewers read a fake |
| 376 | +// trend. Bar / point marks are the right alternative. |
| 377 | + |
| 378 | +function auditLineOnCategoricalX(out: AuditFinding[], layer: Layer, idx: number): void { |
| 379 | + if (layer.mark !== "line" && layer.mark !== "area") return; |
| 380 | + const x = layer.encoding?.x; |
| 381 | + if (!x || typeof x === "string") return; |
| 382 | + // Look only at explicit nominal/ordinal-no-order signal. |
| 383 | + const t = (x as { type?: string }).type; |
| 384 | + if (t !== "nominal") return; |
| 385 | + out.push({ |
| 386 | + rule_id: "AUDIT-05", |
| 387 | + severity: "medium", |
| 388 | + message: `Layer ${idx}: ${layer.mark} mark connects across a nominal x-encoding. The connecting line implies ordered progression that nominal categories don't have — readers see a fake trend.`, |
| 389 | + suggestion: |
| 390 | + 'Switch to `mark: "bar"` (or `mark: "point"`), or change x.type to `"ordinal"` if there is a real ordering.', |
| 391 | + path: `/layers/${idx}/encoding/x`, |
| 392 | + }); |
| 393 | +} |
| 394 | + |
| 395 | +// --------------------------------------------------------------------------- |
| 396 | +// Rule: AUDIT-13 — bar chart on a quantitative x |
| 397 | +// --------------------------------------------------------------------------- |
| 398 | +// Bars on a continuous numeric axis usually wants `mark: "rect"` (binned |
| 399 | +// heat) or histogram semantics. A `mark: "bar"` on quantitative x leaves |
| 400 | +// gaps that imply each bar is a categorical bucket; readers misjudge |
| 401 | +// magnitudes when bar widths don't sum to the axis range. |
| 402 | + |
| 403 | +function auditBarOnQuantitativeX(out: AuditFinding[], layer: Layer, idx: number): void { |
| 404 | + if (layer.mark !== "bar") return; |
| 405 | + const x = layer.encoding?.x; |
| 406 | + if (!x || typeof x === "string") return; |
| 407 | + const t = (x as { type?: string }).type; |
| 408 | + if (t !== "quantitative") return; |
| 409 | + out.push({ |
| 410 | + rule_id: "AUDIT-13", |
| 411 | + severity: "low", |
| 412 | + message: `Layer ${idx}: bar mark on a quantitative x-encoding. Bars suggest categorical bins, but a continuous x suggests a histogram or rect mark.`, |
| 413 | + suggestion: |
| 414 | + 'Either switch to `mark: "rect"` for binned data, or change x.type to `"ordinal"` if each bar is a discrete category.', |
| 415 | + path: `/layers/${idx}/encoding/x`, |
| 416 | + }); |
| 417 | +} |
| 418 | + |
| 419 | +// --------------------------------------------------------------------------- |
| 420 | +// Rule: AUDIT-12 — too many pie / arc slices |
| 421 | +// --------------------------------------------------------------------------- |
| 422 | +// Cleveland-McGill rank angle judgment near the bottom; past ~5 slices |
| 423 | +// readers can't distinguish 18% from 22%. Past 7 the whole chart is |
| 424 | +// noise. Detection: when the spec has a `mark: "arc"` layer and the |
| 425 | +// caller passes `rowCount` (or colorCardinality), flag the threshold. |
| 426 | + |
| 427 | +function auditTooManyArcSlices( |
| 428 | + out: AuditFinding[], |
| 429 | + spec: GlyphSpec, |
| 430 | + rowCount: number | undefined, |
| 431 | +): void { |
| 432 | + const hasArc = spec.layers.some((l) => l.mark === "arc"); |
| 433 | + if (!hasArc) return; |
| 434 | + const n = rowCount ?? 0; |
| 435 | + if (n <= 7) return; |
| 436 | + out.push({ |
| 437 | + rule_id: "AUDIT-12", |
| 438 | + severity: "medium", |
| 439 | + message: `Pie / donut chart with ${n} slices. Angle comparison drops sharply past ~5 slices; past 7 the chart is essentially unreadable.`, |
| 440 | + suggestion: |
| 441 | + "Group small slices into an 'Other' bucket, or switch to a horizontal bar chart sorted by value.", |
| 442 | + }); |
| 443 | +} |
| 444 | + |
| 445 | +// --------------------------------------------------------------------------- |
| 446 | +// Rule: AUDIT-14 — overlay layer count too high |
| 447 | +// --------------------------------------------------------------------------- |
| 448 | +// More than 4 overlay layers makes individual series indistinguishable. |
| 449 | +// Faceting / small multiples reads better past that count. Doesn't fire |
| 450 | +// for compose specs (a compose with 20 silhouette-path children isn't |
| 451 | +// "overlay confusion", it's a hand-laid illustration). |
| 452 | + |
| 453 | +function auditOverlayLayerCount(out: AuditFinding[], spec: GlyphSpec): void { |
| 454 | + if (spec.layers.length <= 4) return; |
| 455 | + if (spec.facet) return; // small-multiple layout handles the cognitive load |
| 456 | + out.push({ |
| 457 | + rule_id: "AUDIT-14", |
| 458 | + severity: "low", |
| 459 | + message: `Chart has ${spec.layers.length} overlay layers. Viewer accuracy on individual series drops sharply past ~4 overlaid layers.`, |
| 460 | + suggestion: |
| 461 | + "Use `spec.facet` to split into small multiples, or normalize the data and use a single layer with a color encoding.", |
| 462 | + }); |
| 463 | +} |
| 464 | + |
| 465 | +// --------------------------------------------------------------------------- |
| 466 | +// Rule: AUDIT-15 — multi-layer chart with no title |
| 467 | +// --------------------------------------------------------------------------- |
| 468 | +// A bare multi-layer chart is unreadable without context. Even a 30- |
| 469 | +// character title anchors what the reader is comparing. Single-layer |
| 470 | +// charts with a clear y-encoding don't trigger; this is for layered or |
| 471 | +// faceted charts where ambiguity is real. |
| 472 | + |
| 473 | +function auditMissingTitle(out: AuditFinding[], spec: GlyphSpec): void { |
| 474 | + if (spec.title && spec.title.trim().length > 0) return; |
| 475 | + const layered = spec.layers.length > 1; |
| 476 | + const faceted = !!spec.facet; |
| 477 | + const hasColor = spec.layers.some((l) => { |
| 478 | + const c = l.encoding?.color; |
| 479 | + if (typeof c === "string") return true; |
| 480 | + return (c as { field?: string } | undefined)?.field !== undefined; |
| 481 | + }); |
| 482 | + if (!layered && !faceted && !hasColor) return; |
| 483 | + out.push({ |
| 484 | + rule_id: "AUDIT-15", |
| 485 | + severity: "low", |
| 486 | + message: |
| 487 | + "Multi-layer / faceted / color-encoded chart has no title. Readers need a title to anchor what the chart is comparing.", |
| 488 | + suggestion: "Add a `title` to the spec — even a short one prevents misreading.", |
| 489 | + path: "/title", |
| 490 | + }); |
| 491 | +} |
| 492 | + |
351 | 493 | function channelDomain(c: Channel | undefined): ReadonlyArray<unknown> | undefined { |
352 | 494 | if (!c || typeof c === "string") return undefined; |
353 | 495 | const scale = (c as { scale?: { domain?: ReadonlyArray<unknown> } }).scale; |
|
0 commit comments