feat(bar): add dumbbell as a Bar variant (#107)
Documents the dumbbell in type-bar.md's existing Variants section, alongside grouped and stacked bars. Reference-only, matching the precedent those two set and the one #100 landed for datalake: no example assets, no gallery entries. Not a new visual type. The count stays at 28 and SKILL.md is untouched -- no selection-table row, no frontmatter hook, no manifest description change. Covers layout on the shared 1000x500 composition, an element pattern, and five honesty rules: no truncated axis, one scale with both endpoints labelled, the connector is a gap and not a trajectory, a stated row order, and no imputed or silently dropped endpoints. Two of those rules live in a formula rather than a drawing, so they ship as a checker with adversarial fixtures per ADR 0005: - Domain resolution is exhaustive over sign. A rule phrased as "all-positive / all-negative" silently drops data that only touches zero, and drops all-zero data entirely -- where floor == ceil == 0 and the position formula divides by zero. resolve_domain() partitions the space, guarantees ceil > floor, and returns a finite fallback span for all-zero input so every dot lands on the floor. - Non-text contrast is carried by the boundary, not the fill. Accent on paper is 2.86:1 skin-wide, and shape redundancy does not waive WCAG 1.4.11, so the solid endpoint takes a 1px ink stroke (11.82:1) and the connector 55% ink on light / 40% on dark (3.19:1 and 3.24:1). The checker reads these tokens out of the reference, so prose and thresholds cannot drift apart. scripts/test-verify-dumbbell.py covers both polarities: all-zero, zero-touching, mixed-sign, empty and non-finite input, observed-extremes truncation, and the sub-3:1 treatments this replaces. Wired into CI and CONTRIBUTING alongside the treemap and geometry gates. Other things the geometry and house style forced: the focal bar pattern (12% tint plus accent stroke) does not survive at a 6px dot, so the pair separates by fill weight; a 64px row pitch from y=96 overflows the y=420 plot floor at seven rows, so pitch and origin are tabulated per row count; rgb(45,49,66) IS the dark paper, so light-theme hairlines composite to 1.000:1 and must invert; label offsets keyed to start/end put both labels inside the pair on a decreasing row; a value on the domain floor collided with its category label; and the value axis was missing the Geist Mono 7px title every other cartesian type carries. Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
0xDarkMatter
Claude Opus 5
parent
0947fdb6e5
commit
29917724f8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "diagram-design",
|
||||
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.5",
|
||||
"author": {
|
||||
"name": "Cathryn Lavery",
|
||||
"url": "https://github.com/cathrynlavery"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "diagram-design",
|
||||
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.5",
|
||||
"author": {
|
||||
"name": "Cathryn Lavery",
|
||||
"url": "https://github.com/cathrynlavery"
|
||||
|
||||
@@ -197,6 +197,16 @@ jobs:
|
||||
id: treemap_tests
|
||||
run: python scripts/test-verify-treemap.py
|
||||
|
||||
- name: Verify dumbbell domain and contrast rules
|
||||
if: always()
|
||||
id: dumbbell
|
||||
run: python scripts/verify-dumbbell.py
|
||||
|
||||
- name: Verify dumbbell checker
|
||||
if: always()
|
||||
id: dumbbell_tests
|
||||
run: python scripts/test-verify-dumbbell.py
|
||||
|
||||
- name: Verify generated icon assets
|
||||
if: always()
|
||||
id: icons
|
||||
@@ -233,4 +243,6 @@ jobs:
|
||||
echo "| Label Geometry Checker | ${{ steps.geometry_tests.outcome == 'success' && '✅ Passed' || (steps.geometry_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Treemap Area & Label Fit | ${{ steps.treemap.outcome == 'success' && '✅ Passed' || (steps.treemap.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Treemap Checker | ${{ steps.treemap_tests.outcome == 'success' && '✅ Passed' || (steps.treemap_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Dumbbell Domain & Contrast | ${{ steps.dumbbell.outcome == 'success' && '✅ Passed' || (steps.dumbbell.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Dumbbell Checker | ${{ steps.dumbbell_tests.outcome == 'success' && '✅ Passed' || (steps.dumbbell_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+3
-1
@@ -58,6 +58,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
|
||||
| Label geometry checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-geometry.py` |
|
||||
| Treemap cells match the values they are labelled with, and labels fit | `python3 scripts/verify-treemap.py --all` |
|
||||
| Treemap checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-treemap.py` |
|
||||
| Dumbbell domain resolves finitely and its marks clear 3:1 | `python3 scripts/verify-dumbbell.py` |
|
||||
| Dumbbell checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-dumbbell.py` |
|
||||
| Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files |
|
||||
|
||||
The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter.
|
||||
@@ -84,7 +86,7 @@ python3 scripts/test-plugin-package.py \
|
||||
&& python3 scripts/verify-geometry.py --all \
|
||||
&& python3 scripts/test-verify-geometry.py \
|
||||
&& python3 scripts/verify-treemap.py --all \
|
||||
&& python3 scripts/test-verify-treemap.py
|
||||
&& python3 scripts/test-verify-treemap.py \n && python3 scripts/verify-dumbbell.py \n && python3 scripts/test-verify-dumbbell.py
|
||||
```
|
||||
|
||||
### If a gate fails
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Adversarial tests for verify-dumbbell.py - both polarities.
|
||||
|
||||
A checker that only proves the good cases pass is half a checker: it cannot
|
||||
tell you it would have caught the bug. Every rule below is tested twice, once
|
||||
for the case that must pass and once for the case that must be rejected.
|
||||
|
||||
The zero cases are the reason this file exists. A sign-based domain rule reads
|
||||
naturally and silently omits two inputs - data that only touches zero, and data
|
||||
that is entirely zero - and the second one divides by zero in the position
|
||||
formula. Both are exercised through the real scaling path here, not asserted in
|
||||
prose.
|
||||
|
||||
Usage:
|
||||
python3 scripts/test-verify-dumbbell.py
|
||||
|
||||
Exit: 0 all passed, 1 a case behaved wrongly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CHECKER = ROOT / "scripts/verify-dumbbell.py"
|
||||
|
||||
|
||||
def _load_checker():
|
||||
"""Import the checker despite its hyphenated filename."""
|
||||
spec = importlib.util.spec_from_file_location("verify_dumbbell", CHECKER)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("cannot load %s" % CHECKER)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verify_dumbbell = _load_checker()
|
||||
|
||||
resolve_domain = verify_dumbbell.resolve_domain
|
||||
scale = verify_dumbbell.scale
|
||||
contrast = verify_dumbbell.contrast
|
||||
composite = verify_dumbbell.composite
|
||||
is_truncated = verify_dumbbell.is_truncated
|
||||
DomainError = verify_dumbbell.DomainError
|
||||
|
||||
FAILURES = []
|
||||
|
||||
|
||||
def ok(condition, label):
|
||||
if condition:
|
||||
sys.stdout.write("OK: %s\n" % label)
|
||||
else:
|
||||
FAILURES.append(label)
|
||||
sys.stdout.write("FAIL: %s\n" % label)
|
||||
|
||||
|
||||
# === DOMAIN - the cases a sign-based rule drops =============================
|
||||
|
||||
def test_all_zero_is_finite():
|
||||
"""The case that divides by zero if the rule stops at 'positive/negative'."""
|
||||
floor, ceiling = resolve_domain([0, 0, 0, 0])
|
||||
ok(ceiling > floor, "all-zero data yields a non-degenerate domain")
|
||||
coordinates = [scale(v, floor, ceiling) for v in (0, 0, 0, 0)]
|
||||
ok(all(math.isfinite(x) for x in coordinates),
|
||||
"all-zero data produces finite coordinates")
|
||||
ok(all(x == verify_dumbbell.PLOT_X0 for x in coordinates),
|
||||
"all-zero data puts every dot on the floor, which is the truth")
|
||||
|
||||
|
||||
def test_zero_touching_domains():
|
||||
floor, ceiling = resolve_domain([0, 5, 12])
|
||||
ok((floor, ceiling) == (0.0, 20.0),
|
||||
"positive data touching zero anchors the floor at zero")
|
||||
floor, ceiling = resolve_domain([-12, -5, 0])
|
||||
ok((floor, ceiling) == (-20.0, 0.0),
|
||||
"negative data touching zero anchors the ceiling at zero")
|
||||
for values in ([0, 5, 12], [-12, -5, 0]):
|
||||
floor, ceiling = resolve_domain(values)
|
||||
ok(all(math.isfinite(scale(v, floor, ceiling)) for v in values),
|
||||
"zero-touching %s scales finitely" % ("positive" if values[-1] else "negative"))
|
||||
|
||||
|
||||
def test_sign_cases_resolve():
|
||||
ok(resolve_domain([18, 66, 71]) == (0.0, 100.0),
|
||||
"all-positive data anchors the floor at zero")
|
||||
ok(resolve_domain([-71, -18]) == (-100.0, 0.0),
|
||||
"all-negative data anchors the ceiling at zero")
|
||||
floor, ceiling = resolve_domain([-20, 5, 40])
|
||||
ok(floor < 0 < ceiling, "data crossing zero brackets both sides")
|
||||
|
||||
|
||||
def test_identical_and_tiny_values():
|
||||
floor, ceiling = resolve_domain([7, 7])
|
||||
ok(ceiling > floor and math.isfinite(scale(7, floor, ceiling)),
|
||||
"a pair of identical non-zero values still scales")
|
||||
floor, ceiling = resolve_domain([0.0004, 0.0009])
|
||||
ok(ceiling > floor, "sub-unit magnitudes still yield a usable domain")
|
||||
|
||||
|
||||
def test_empty_and_non_finite_rejected():
|
||||
"""The other polarity: garbage in must raise, not return a silent domain."""
|
||||
for label, values in (("empty input", []),
|
||||
("NaN in input", [1.0, float("nan")]),
|
||||
("infinity in input", [1.0, float("inf")])):
|
||||
try:
|
||||
resolve_domain(values)
|
||||
except DomainError:
|
||||
ok(True, "%s is rejected" % label)
|
||||
else:
|
||||
ok(False, "%s was NOT rejected" % label)
|
||||
|
||||
|
||||
def test_truncated_bounds_are_detected():
|
||||
"""The honesty rule, executable: observed min/max must read as truncation."""
|
||||
values = [18, 66, 34, 71]
|
||||
ok(is_truncated(values, 18.0, 71.0),
|
||||
"bounds taken from the observed extremes are reported as truncation")
|
||||
resolved = resolve_domain(values)
|
||||
ok(not is_truncated(values, *resolved),
|
||||
"correctly resolved bounds are NOT reported as truncation")
|
||||
|
||||
|
||||
def test_scaling_preserves_gap_ratios():
|
||||
"""Two rows on one scale: drawn gaps must stay proportional to real ones."""
|
||||
values = [10, 30, 40, 80]
|
||||
floor, ceiling = resolve_domain(values)
|
||||
gap_a = scale(30, floor, ceiling) - scale(10, floor, ceiling)
|
||||
gap_b = scale(80, floor, ceiling) - scale(40, floor, ceiling)
|
||||
ok(abs(gap_b / gap_a - 2.0) < 1e-9,
|
||||
"a doubled data gap draws exactly twice as wide")
|
||||
|
||||
|
||||
# === CONTRAST - both polarities =============================================
|
||||
|
||||
def test_documented_marks_clear_the_threshold():
|
||||
for label, ink, alpha, paper in verify_dumbbell.CONNECTORS:
|
||||
ratio = contrast(composite(ink, alpha, paper), paper)
|
||||
ok(ratio >= 3.0, "%s clears 3:1 (%.3f:1)" % (label, ratio))
|
||||
for label, stroke, paper in verify_dumbbell.ENDPOINT_STROKES:
|
||||
ratio = contrast(stroke, paper)
|
||||
ok(ratio >= 3.0, "%s clears 3:1 (%.3f:1)" % (label, ratio))
|
||||
|
||||
|
||||
def test_rejected_treatments_are_caught():
|
||||
"""The other polarity: the treatments this PR replaced must still fail."""
|
||||
hairline = composite("#2d3142", 0.25, "#f5f5f5")
|
||||
ok(contrast(hairline, "#f5f5f5") < 3.0,
|
||||
"the original 25%% hairline connector is correctly under 3:1")
|
||||
ok(contrast("#eb6c36", "#f5f5f5") < 3.0,
|
||||
"an unstroked accent endpoint is correctly under 3:1 on light paper")
|
||||
ok(contrast("#7a8399", "#f5f5f5") < 4.5,
|
||||
"the soft token is correctly under 4.5:1 and unusable for text")
|
||||
|
||||
|
||||
def test_checker_fails_closed_on_a_stripped_reference(tmp_path: Path):
|
||||
empty = tmp_path / "type-bar.md"
|
||||
empty.write_text("# Bar\n\nNo variants here.\n", encoding="utf-8")
|
||||
findings = verify_dumbbell.check_reference(empty)
|
||||
ok(bool(findings), "a reference with no dumbbell section is a finding, not a pass")
|
||||
|
||||
missing = tmp_path / "partial.md"
|
||||
missing.write_text("# Bar\n\n## Variants\n\n- Dumbbell: floor and ceil.\n",
|
||||
encoding="utf-8")
|
||||
findings = verify_dumbbell.check_reference(missing)
|
||||
ok(bool(findings),
|
||||
"a dumbbell section missing the connector tokens is a finding")
|
||||
|
||||
absent = tmp_path / "nope.md"
|
||||
ok(bool(verify_dumbbell.check_reference(absent)),
|
||||
"a missing reference file is a finding, not a pass")
|
||||
|
||||
|
||||
def test_shipped_reference_passes():
|
||||
findings = verify_dumbbell.check_reference(verify_dumbbell.REFERENCE)
|
||||
ok(not findings,
|
||||
"the shipped reference documents every token the checker verifies")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import tempfile
|
||||
|
||||
test_all_zero_is_finite()
|
||||
test_zero_touching_domains()
|
||||
test_sign_cases_resolve()
|
||||
test_identical_and_tiny_values()
|
||||
test_empty_and_non_finite_rejected()
|
||||
test_truncated_bounds_are_detected()
|
||||
test_scaling_preserves_gap_ratios()
|
||||
test_documented_marks_clear_the_threshold()
|
||||
test_rejected_treatments_are_caught()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
test_checker_fails_closed_on_a_stripped_reference(Path(directory))
|
||||
test_shipped_reference_passes()
|
||||
|
||||
if FAILURES:
|
||||
sys.stderr.write("\n%d case(s) behaved wrongly:\n" % len(FAILURES))
|
||||
for failure in FAILURES:
|
||||
sys.stderr.write(" - %s\n" % failure)
|
||||
return 1
|
||||
sys.stdout.write("\nAll dumbbell checker tests passed\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the two dumbbell rules that live in a formula rather than in a drawing.
|
||||
|
||||
Both rules in `references/type-bar.md` are formula-level: a generator implements
|
||||
them arithmetically, and both fail in ways that render perfectly. Prose alone
|
||||
cannot defend either, so both are executable here and the reference is checked
|
||||
against this module rather than the other way round.
|
||||
|
||||
1. DOMAIN RESOLUTION - the axis bounds are a function of the data's range, never
|
||||
of its observed extremes. Taking `min`/`max` as the bounds IS the truncation
|
||||
the reference forbids: the plot width is fixed, so a narrowed domain raises
|
||||
px-per-unit and draws every gap wider. The subtle case is zero. A sign-based
|
||||
rule that says "all-positive" and "all-negative" silently omits data that
|
||||
only touches zero, and omits the all-zero dataset entirely - where the naive
|
||||
reading yields `floor == ceil == 0` and the position formula divides by zero.
|
||||
`resolve_domain` partitions the space exhaustively and guarantees
|
||||
`ceil > floor`, so coordinates are always finite.
|
||||
|
||||
2. NON-TEXT CONTRAST - the solid endpoint and the connector are the marks that
|
||||
carry "which series" and "which pair". WCAG 1.4.11 asks 3:1 of a graphical
|
||||
object required to understand the content, and shape redundancy does not
|
||||
waive it: a reader still has to see the solid mark's boundary and the line
|
||||
joining the pair. Accent-on-paper is 2.86:1 skin-wide and cannot carry that,
|
||||
so the boundary is carried by a stroke and the connector by an alpha that
|
||||
clears 3:1 in both themes. The thresholds are checked here against the tokens
|
||||
the reference actually documents, so the two cannot drift apart.
|
||||
|
||||
The check FAILS CLOSED. A reference this cannot parse, or a token it cannot
|
||||
find, is a finding - never a silent pass.
|
||||
|
||||
Usage:
|
||||
python3 scripts/verify-dumbbell.py
|
||||
python3 scripts/verify-dumbbell.py --reference path/to/type-bar.md
|
||||
|
||||
Exit: 0 clean, 1 findings, 2 usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
REFERENCE = ROOT / "skills/diagram-design/references/type-bar.md"
|
||||
|
||||
# --- composition constants, quoted from the reference -----------------------
|
||||
PLOT_X0 = 200 # left edge of the plot area
|
||||
PLOT_WIDTH = 760 # 200 -> 960
|
||||
MIN_GAP_PX = 16 # below this two r=6 marks read as one blob
|
||||
|
||||
# All-zero data has no range to scale against. A finite fallback span keeps the
|
||||
# position formula defined; every dot lands on the floor, which is the truth.
|
||||
ALL_ZERO_SPAN = 1.0
|
||||
|
||||
# --- skin tokens ------------------------------------------------------------
|
||||
PAPER_LIGHT, PAPER_DARK = "#f5f5f5", "#2d3142"
|
||||
WCAG_NON_TEXT = 3.0 # 1.4.11, graphical objects
|
||||
|
||||
# (label, ink hex, alpha, paper) - the connector in each theme
|
||||
CONNECTORS = [
|
||||
("connector (light)", "#2d3142", 0.55, PAPER_LIGHT),
|
||||
("connector (dark)", "#f5f5f5", 0.40, PAPER_DARK),
|
||||
]
|
||||
# (label, stroke hex, paper) - the boundary of the solid endpoint
|
||||
ENDPOINT_STROKES = [
|
||||
("solid endpoint stroke (light)", "#2d3142", PAPER_LIGHT),
|
||||
("solid endpoint stroke (dark)", "#f5f5f5", PAPER_DARK),
|
||||
]
|
||||
# Tokens the reference must actually name, so prose and code cannot drift.
|
||||
REQUIRED_TOKENS = [
|
||||
"rgba(45,49,66,0.55)",
|
||||
"rgba(245,245,245,0.40)",
|
||||
]
|
||||
|
||||
|
||||
class DomainError(ValueError):
|
||||
"""Raised when a domain cannot be resolved at all."""
|
||||
|
||||
|
||||
# === DOMAIN =================================================================
|
||||
|
||||
def _nice_magnitude(value: float) -> float:
|
||||
"""Smallest round number >= |value|, from the 1 / 2 / 2.5 / 5 / 10 ladder."""
|
||||
magnitude = abs(value)
|
||||
if magnitude == 0:
|
||||
return 0.0
|
||||
exponent = math.floor(math.log10(magnitude))
|
||||
base = 10.0 ** exponent
|
||||
for step in (1.0, 2.0, 2.5, 5.0, 10.0):
|
||||
candidate = step * base
|
||||
if candidate >= magnitude - 1e-12:
|
||||
return candidate
|
||||
return 10.0 * base
|
||||
|
||||
|
||||
def resolve_domain(values):
|
||||
"""Return (floor, ceil) for a dumbbell's value axis.
|
||||
|
||||
Exhaustive over the sign of the data, including the two cases a sign-based
|
||||
rule drops: data that only touches zero, and data that is entirely zero.
|
||||
Guarantees ceil > floor, so the position formula never divides by zero.
|
||||
"""
|
||||
numbers = [float(v) for v in values]
|
||||
if not numbers:
|
||||
raise DomainError("no values: a dumbbell needs at least one pair")
|
||||
if any(math.isnan(v) or math.isinf(v) for v in numbers):
|
||||
raise DomainError("non-finite value in input")
|
||||
|
||||
low, high = min(numbers), max(numbers)
|
||||
|
||||
if low == 0.0 and high == 0.0:
|
||||
floor, ceiling = 0.0, ALL_ZERO_SPAN # every dot sits on the floor
|
||||
elif low >= 0.0:
|
||||
floor, ceiling = 0.0, _nice_magnitude(high)
|
||||
elif high <= 0.0:
|
||||
floor, ceiling = -_nice_magnitude(low), 0.0
|
||||
else:
|
||||
floor, ceiling = -_nice_magnitude(low), _nice_magnitude(high)
|
||||
|
||||
if not ceiling > floor:
|
||||
raise DomainError(
|
||||
"degenerate domain %r..%r from values %r" % (floor, ceiling, numbers)
|
||||
)
|
||||
return floor, ceiling
|
||||
|
||||
|
||||
def scale(value: float, floor: float, ceiling: float) -> float:
|
||||
"""Map a value onto the plot's x axis. Mirrors the reference's formula."""
|
||||
span = ceiling - floor
|
||||
if span <= 0:
|
||||
raise DomainError("non-positive span %r" % (span,))
|
||||
return PLOT_X0 + (float(value) - floor) / span * PLOT_WIDTH
|
||||
|
||||
|
||||
def is_truncated(values, floor: float, ceiling: float) -> bool:
|
||||
"""True when the bounds hug the data instead of following its sign.
|
||||
|
||||
This is the failure the honesty rule exists to prevent: bounds taken from
|
||||
the observed extremes rather than resolved from the data's range.
|
||||
"""
|
||||
resolved_floor, resolved_ceiling = resolve_domain(values)
|
||||
return (floor, ceiling) != (resolved_floor, resolved_ceiling)
|
||||
|
||||
|
||||
# === CONTRAST ===============================================================
|
||||
|
||||
def _channel(component: int) -> float:
|
||||
fraction = component / 255.0
|
||||
if fraction <= 0.04045:
|
||||
return fraction / 12.92
|
||||
return ((fraction + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def relative_luminance(color: str) -> float:
|
||||
value = color.lstrip("#")
|
||||
red, green, blue = (int(value[i:i + 2], 16) for i in (0, 2, 4))
|
||||
return 0.2126 * _channel(red) + 0.7152 * _channel(green) + 0.0722 * _channel(blue)
|
||||
|
||||
|
||||
def contrast(foreground: str, background: str) -> float:
|
||||
first, second = relative_luminance(foreground), relative_luminance(background)
|
||||
lighter, darker = max(first, second), min(first, second)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
def composite(foreground: str, alpha: float, background: str) -> str:
|
||||
"""Flatten a translucent stroke onto its background."""
|
||||
fore = [int(foreground.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
|
||||
back = [int(background.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
|
||||
blended = [round(fore[i] * alpha + back[i] * (1 - alpha)) for i in range(3)]
|
||||
return "#%02x%02x%02x" % tuple(blended)
|
||||
|
||||
|
||||
# === CHECKS =================================================================
|
||||
|
||||
def check_domain_rules():
|
||||
"""Exercise the scaling path over every sign case, including the zero ones."""
|
||||
findings = []
|
||||
cases = [
|
||||
("all zero", [0, 0, 0, 0]),
|
||||
("zero-touching positive", [0, 0, 5, 12]),
|
||||
("zero-touching negative", [-12, -5, 0, 0]),
|
||||
("all positive", [18, 66, 34, 71]),
|
||||
("all negative", [-71, -34, -66, -18]),
|
||||
("crossing zero", [-20, 5, 40, -3]),
|
||||
("single identical pair", [7, 7]),
|
||||
("tiny magnitudes", [0.0004, 0.0009]),
|
||||
]
|
||||
for label, values in cases:
|
||||
try:
|
||||
floor, ceiling = resolve_domain(values)
|
||||
except DomainError as error:
|
||||
findings.append("%s: domain unresolved (%s)" % (label, error))
|
||||
continue
|
||||
if not ceiling > floor:
|
||||
findings.append("%s: ceil %r not above floor %r" % (label, ceiling, floor))
|
||||
continue
|
||||
for value in values:
|
||||
x = scale(value, floor, ceiling)
|
||||
if not math.isfinite(x):
|
||||
findings.append("%s: non-finite coordinate for %r" % (label, value))
|
||||
elif not (PLOT_X0 - 1e-6 <= x <= PLOT_X0 + PLOT_WIDTH + 1e-6):
|
||||
findings.append(
|
||||
"%s: %r maps to %.3f, outside the plot area" % (label, value, x)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def check_contrast_rules():
|
||||
findings = []
|
||||
for label, ink, alpha, paper in CONNECTORS:
|
||||
ratio = contrast(composite(ink, alpha, paper), paper)
|
||||
if ratio < WCAG_NON_TEXT:
|
||||
findings.append(
|
||||
"%s: %.3f:1 against paper, under the %.1f:1 WCAG 1.4.11 asks"
|
||||
% (label, ratio, WCAG_NON_TEXT)
|
||||
)
|
||||
for label, stroke, paper in ENDPOINT_STROKES:
|
||||
ratio = contrast(stroke, paper)
|
||||
if ratio < WCAG_NON_TEXT:
|
||||
findings.append(
|
||||
"%s: %.3f:1 against paper, under the %.1f:1 WCAG 1.4.11 asks"
|
||||
% (label, ratio, WCAG_NON_TEXT)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def check_reference(path: Path):
|
||||
"""Fail closed: the reference must name the tokens this module verifies."""
|
||||
findings = []
|
||||
if not path.is_file():
|
||||
return ["reference not found: %s" % path]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "Dumbbell" not in text:
|
||||
return ["reference %s carries no dumbbell section to check" % path.name]
|
||||
for token in REQUIRED_TOKENS:
|
||||
if token not in text:
|
||||
findings.append(
|
||||
"reference does not document %s, so the checked value is not the "
|
||||
"shipped one" % token
|
||||
)
|
||||
if not re.search(r"\bfloor\b", text) or not re.search(r"\bceil\b", text):
|
||||
findings.append("reference does not name the domain bounds the formula uses")
|
||||
return findings
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Verify the dumbbell's domain and non-text-contrast rules."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference", type=Path, default=REFERENCE,
|
||||
help="path to type-bar.md (default: the shipped reference)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
findings = []
|
||||
findings += check_domain_rules()
|
||||
findings += check_contrast_rules()
|
||||
findings += check_reference(args.reference)
|
||||
|
||||
if findings:
|
||||
for finding in findings:
|
||||
sys.stderr.write("FAIL dumbbell: %s\n" % finding)
|
||||
sys.stderr.write("Summary: %d finding(s).\n" % len(findings))
|
||||
return 1
|
||||
sys.stdout.write(
|
||||
"OK dumbbell: domain resolves finitely over every sign case "
|
||||
"(including all-zero), and the documented connector and endpoint "
|
||||
"boundary clear 3:1 in both themes\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bar / Column Chart
|
||||
|
||||
**Best for:** comparing discrete quantities across categories or time intervals — sprint velocity, monthly revenue, feature adoption, cohort counts. Use when each category has a single numeric value and the comparison between bars is the primary message.
|
||||
**Best for:** comparing discrete quantities across categories or time intervals — sprint velocity, monthly revenue, feature adoption, cohort counts. Use when each category has a single numeric value and the comparison between bars is the primary message — or, in the dumbbell variant below, exactly two values whose difference is the message.
|
||||
|
||||
## Layout conventions
|
||||
|
||||
@@ -40,6 +40,64 @@ Focal bar: replace fill with `rgba(235,108,54,0.12)`, stroke with `#eb6c36`, lab
|
||||
|
||||
- **Grouped bars:** two bars per category, side by side. Use `accent` for the primary series and `series-1` for the secondary. Max 2 groups.
|
||||
- **Stacked bars:** segments stacked to total. Use `accent` for the focal segment; muted tints for others. Document the total at the top of each stack.
|
||||
- **Dumbbell:** one row per category, two dots on a single shared horizontal scale joined by a hairline. Use it for a two-state comparison where the *distance* between the ends is the message — before/after, two cohorts, target vs actual. Two series only; a third dot makes the connector meaningless and the figure is a dot plot.
|
||||
|
||||
### Dumbbell layout
|
||||
|
||||
- **Orientation:** horizontal only. Rows stack downward and the value axis runs left → right; a vertical dumbbell forces rotated category labels.
|
||||
- **Plot area:** left margin 200px for row labels — this replaces the 80px margin above — so `x` 200→960, `y` 40→420 inside `0 0 1000 500`. Row labels right-aligned at x=188, Geist 11px 600 ink (the category-label size this type already uses).
|
||||
- **Rows:** the 4–8 bar count cap applies. Pitch and origin are fixed per row count so the block stays inside the plot band — a 64px pitch from y=96 overflows y=420 at seven rows:
|
||||
|
||||
| rows | pitch | first row `y` | last row `y` |
|
||||
| --- | --- | --- | --- |
|
||||
| 4 | 88 | 96 | 360 |
|
||||
| 5 | 64 | 96 | 352 |
|
||||
| 6 | 64 | 76 | 396 |
|
||||
| 7 | 52 | 72 | 384 |
|
||||
| 8 | 48 | 68 | 404 |
|
||||
|
||||
- **Gridlines:** vertical at each tick, `rgba(45,49,66,0.08)` 0.8px, spanning y 56→408. At the domain floor the axis line replaces the gridline rather than doubling it: `rgba(45,49,66,0.25)` 1px, y 40→420.
|
||||
- **Tick labels:** centered under each gridline at y=440, Geist Mono 8px muted.
|
||||
- **Axis title:** the value axis carries the family's axis label — Geist Mono 7px muted, `letter-spacing="0.14em"`, centered at x=580, y=456, below the tick labels and clear of the legend rule. A horizontal axis takes the un-rotated form the scatter x-axis uses, not the `rotate(-90 24 230)` form the column charts apply to their y-axis. The category axis needs no title: the row labels name themselves.
|
||||
- **Dots:** r=6, positioned by value. Style by *series*: the reference end hollow (paper fill, `muted` stroke 1.5px), the focal end solid `accent` with a 1px `ink` stroke. Both marks therefore have a boundary above 3:1 even though the accent fill is not — see below. Fill weight, not hue, carries the pairing.
|
||||
- **Accent marks the series, not a focal row.** The solid dot repeats on every row — the one place this variant departs from the one-accent rule above, because the two ends must be told apart in each pair. Do not additionally accent a "most changed" row; the sort order already carries rank.
|
||||
- **Connector:** `rgba(45,49,66,0.55)` 1px, declared before both dots so the dots cap it. It is not the axis hairline: the connector is what says *these two dots are one row*, so it has to clear 3:1 (0.55 gives 3.19:1; the 0.25 the axis uses gives 1.60:1).
|
||||
- **Endpoint positions round, they never snap.** `x = 200 + (v − floor) ÷ (ceil − floor) × 760`, with `floor` and `ceil` set by the axis rule below, rounded to the nearest integer pixel — at most 0.5px, below one rendered pixel. Data coordinates are exempt from the 4px grid; snapping them moves the data.
|
||||
- **Value labels sit outside the pair, placed by geometry rather than by series.** A focal value *below* its reference reverses the dots, so derive `x_left = min(x_ref, x_focal)` and `x_right = max(x_ref, x_focal)`: left label right-anchored at `x_left − 12`, right label left-anchored at `x_right + 12`, both baseline `y + 4`, Geist Mono 8px `muted`, each still carrying its own series' value. Keying the offsets to start/end instead puts both labels *inside* the pair on every decreasing row.
|
||||
- **Floor exception.** A value sitting on the domain floor lands its label at x=188, right-anchored on baseline `y + 4` — exactly the category label's anchor and baseline, so the two texts overlap. When `x_left − 12 < 200`, centre that label above its dot at baseline `y − 10` instead.
|
||||
- **Legend:** two keys on the legend row — hollow dot, then solid dot, each with its series name. Circular keys centre at `cy=493`, the centre of the bar legend's 10px key rect at y=488, so both sit on the 497 text baseline. The row order goes in the legend or the source line, right-aligned on the same row.
|
||||
|
||||
**Why the dots differ by fill, not hue.** The focal bar pattern above (12% tint + accent stroke) does not transfer to a 6px dot: the 12% tint across a 6px-radius disc contributes roughly 14 square pixels of colour, so the mark reads as its stroke alone and the pair separates by hue only. A solid accent dot against a hollow one separates by shape, survives greyscale and colour-vision deficiency, and leaves hue as redundant encoding.
|
||||
|
||||
**Non-text contrast: the boundary carries it, not the fill.** Accent on paper is 2.86:1 skin-wide, under the 3:1 WCAG 1.4.11 asks of a graphical object needed to understand the content — and shape redundancy does not waive that, because the reader still has to see the mark's edge and the line joining the pair. So neither is left to the accent: the solid endpoint takes a 1px `ink` stroke (11.82:1 against paper, 4.13:1 against its own fill) and the connector takes 55% ink on light, 40% on dark (3.19:1 and 3.24:1). The hollow end already cleared it via its `muted` stroke at 6.11:1 light and 7.07:1 dark. `scripts/verify-dumbbell.py` asserts all four, so a later tint change cannot quietly drop one under the line. The hollow/solid shape difference stays as redundant encoding for greyscale and colour-vision deficiency — do not collapse the two dots to a single fill.
|
||||
|
||||
**Minimum drawn gap — never clamp it.** Both dots carry `r="6"`; the hollow one paints out to 6.75 because its 1.5px stroke straddles the path, so the marks touch at 12.75px of centre separation and below about 16px the pair reads as one blob — on a 0–100 domain across 760px, a data gap of 2.1 units. Do **not** widen the separation to clear it: moving a dot off its scaled position breaks the shared-scale rule below. Keep the true positions and shrink both marks (r=4 touches at 8.75px), or print the two values and mark the row as too close to resolve.
|
||||
|
||||
### Dumbbell element pattern
|
||||
|
||||
```svg
|
||||
<!-- One row. Connector first so the dots cap it; labels outside the pair. -->
|
||||
<line x1="458" y1="96" x2="740" y2="96" stroke="rgba(45,49,66,0.55)" stroke-width="1"/>
|
||||
<circle cx="458" cy="96" r="6" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.5"/>
|
||||
<circle cx="740" cy="96" r="6" fill="#eb6c36" stroke="#2d3142" stroke-width="1"/>
|
||||
<text x="446" y="100" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">34</text>
|
||||
<text x="752" y="100" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace">71</text>
|
||||
<text x="188" y="100" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">Platform</text>
|
||||
```
|
||||
|
||||
Values 34 and 71 on a 0–100 domain compute to 458.4 and 739.6, rounded to 458 and 740. Had this row fallen instead, the accent dot would sit on the left; the two anchors stay tied to left and right, and each label carries its own series' value.
|
||||
|
||||
**Dark theme.** Dots and labels swap as expected — hollow fill `#2d3142` with `#bfc0c0` stroke, solid `#f08a59` with an `#f5f5f5` stroke, labels `#bfc0c0`. **The hairlines must invert too:** gridlines `rgba(245,245,245,0.08)`, axis `rgba(245,245,245,0.20)`, connector `rgba(245,245,245,0.40)` — the connector again heavier than the axis, for the same 3:1 reason. `rgba(45,49,66,…)` *is* the dark paper colour at every alpha value, so a connector, gridline, or axis carried over from light composites to exactly 1.000:1 — the gap encoding and the scale both vanish.
|
||||
|
||||
### Dumbbell honesty rules
|
||||
|
||||
- **Never truncate the value axis.** `floor` and `ceil` follow the data's range, never its observed extremes — taking `min` and `max` as the bounds *is* the truncation. With `lo` and `hi` the smallest and largest values, the four cases are exhaustive: `lo >= 0` anchors `floor = 0` and rounds `ceil` up past `hi`; `hi <= 0` anchors `ceil = 0` and rounds `floor` down past `lo`; `lo < 0 < hi` brackets both sides, and zero then falls inside the plot — give it a line at its scaled position in the axis-line weight, since it is what every gap is read against. The fourth case is the one a sign-based rule drops: **when every value is zero**, `lo == hi == 0` would make `ceil - floor` zero and the position formula divide by zero, so take a finite fallback span (`floor = 0`, `ceil = 1` in the data's unit) and let every dot sit on the floor, which is the truth. Data that merely *touches* zero is covered by the first two cases, not a special one. `scripts/verify-dumbbell.py` implements this and proves finite coordinates for all four. The plot width is fixed, so narrowing the domain raises px-per-unit and draws every gap wider: across the same 760px, an 8-point gap is 61px on a 0–100 axis and 152px on a 40–80 axis. Ratios *between* rows survive the zoom — what inflates is each gap's size against the frame, which is how a reader judges whether a gap is large. The gap is the entire claim, which makes this the most common way a dumbbell lies.
|
||||
- **Both dots share one scale and one unit.** Label both endpoints with their real values. Labelling only the focal end asks the reader to take the geometry on trust.
|
||||
- **The connector is a gap, not a trajectory.** It encodes the distance between two values and nothing about what lies between them — no intermediate points, no rate, no guarantee the change was monotonic. Do not narrate it as movement.
|
||||
- **State the row order** in the legend or source line: by one endpoint, by signed change, by absolute change, or by an order the subject supplies (chronological, geographic, ordinal). "By gap" alone is ambiguous — say signed or absolute. What is not allowed is an unstated order, which reads as arbitrary.
|
||||
- **A row missing an endpoint is disclosed, never imputed and never silently dropped.** Removing incomplete categories without saying so changes the population being compared. Either draw the known end and name the missing value, or drop the row *and* record which rows went and why — a lone dot is otherwise indistinguishable from two coincident dots, which is to say from a genuine zero gap.
|
||||
|
||||
Two of these rules live in a formula rather than a drawing, so they are executable: `scripts/verify-dumbbell.py` resolves the domain over every sign case and asserts finite coordinates and 3:1 marks, and `scripts/test-verify-dumbbell.py` exercises both polarities — including all-zero and zero-touching data, and the sub-3:1 treatments this replaced. The checker reads the tokens out of this file, so prose and thresholds cannot drift apart. A shipped dumbbell example would additionally owe a check of drawn positions against the values printed at them.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
Reference in New Issue
Block a user