diff --git a/src/flightdeck/report/charts.py b/src/flightdeck/report/charts.py
index bc1d84e..d0418c8 100644
--- a/src/flightdeck/report/charts.py
+++ b/src/flightdeck/report/charts.py
@@ -34,10 +34,32 @@ def _nice_top(value: float) -> float:
return raw * 10
+def _nice_step(span: float) -> float:
+ """Smallest 1/2/2.5/5 x 10^k step that cuts span into roughly three intervals."""
+ raw = span / 3
+ magnitude = 10 ** math.floor(math.log10(raw))
+ for mult in (1, 2, 2.5, 5, 10):
+ if magnitude * mult >= raw:
+ return magnitude * mult
+ return magnitude * 10
+
+
def _fmt(value: float, decimals: int | None = None) -> str:
if decimals is None:
decimals = 0 if abs(value) >= 100 or value == int(value) else 1
- return f"{value:,.{decimals}f}"
+ text = f"{value:,.{decimals}f}"
+ # Never a signed zero. format.money makes the same promise for currency and
+ # _money for the bar labels; once ticks can go negative, "-0.0 h" would sit on
+ # the same axis as "0 h" and read as two different zeros.
+ return text.lstrip("-") if float(text.replace(",", "")) == 0 else text
+
+
+def _coord(value: float) -> str:
+ """A coordinate rendered at its natural precision: whole numbers keep their
+ integer form. The zero line lands exactly on the plot floor whenever the
+ domain floors at zero, and this keeps that emission identical to the
+ fixed-baseline output it replaced."""
+ return str(int(value)) if float(value).is_integer() else f"{value:.1f}"
def _money(value: float, unit: str) -> str:
@@ -49,17 +71,57 @@ def _money(value: float, unit: str) -> str:
return f"{'−' if negative else ''}{unit}{_fmt(abs(value))}"
-def _grid_and_axis(top: float, unit: str, plot_h: float) -> str:
- parts = []
- for index in range(1, 4): # 3 hairlines + baseline
- y = _PAD_T + plot_h * (1 - index / 3)
- tick = top * index / 3
- parts.append(f'')
- parts.append(
- f'{_fmt(tick)}{unit}'
- )
- baseline_y = _PAD_T + plot_h
- parts.append(f'')
+def _tick_decimals(step: float) -> int:
+ """Decimals enough to tell one tick from the next. _fmt picks its precision from
+ the value, which collapses two adjacent small ticks to the same label (-0.05 and
+ -0.10 both render "-0.1"); a tick scale has to be read off the step instead."""
+ decimals = max(0, -math.floor(math.log10(step)))
+ if round(step, decimals) != step: # a 2.5-style step needs one more
+ decimals += 1
+ return min(decimals, 6)
+
+
+def _tick(y: float, value: float, unit: str, decimals: int | None = None, rule: bool = True) -> str:
+ line = f'' if rule else ""
+ return (
+ f'{line}{_fmt(value, decimals)}{unit}'
+ )
+
+
+def _grid_and_axis(top: float, unit: str, plot_h: float, bottom: float = 0.0, step: float = 0.0) -> str:
+ """Gridlines and the zero axis over the domain [bottom, top].
+
+ A domain floored at zero keeps the established framing -- three hairlines at
+ thirds of ``top``, the axis ruling the plot floor where zero is self-evident
+ and needs no tick. Those charts, column_chart included, render byte-for-byte
+ as before, with one deliberate exception: a label that rounds to zero loses
+ its minus sign (see _fmt).
+
+ A two-sided domain instead walks multiples of ``step``. Zero always lands on
+ a labeled position and so does the domain minimum: without them the axis is
+ just a heavier rule at an arbitrary height, and a reader can see the dip but
+ not read how deep it goes. The hairline count is whatever the step yields
+ (2-4 in practice) rather than a fixed three, because on a two-sided scale the
+ tick values have to be readable numbers before they are a tidy count."""
+ if bottom == 0.0:
+ parts = [
+ _tick(_PAD_T + plot_h * (1 - index / 3), top * index / 3, unit)
+ for index in range(1, 4) # 3 hairlines + baseline
+ ]
+ zero = _coord(_PAD_T + plot_h)
+ else:
+ span = top - bottom
+ decimals = _tick_decimals(step)
+ parts = []
+ for k in range(round(bottom / step), round(top / step) + 1):
+ value = k * step
+ y = _PAD_T + plot_h * (1 - (value - bottom) / span)
+ # Zero's rule is the axis itself, drawn below; a hairline there too
+ # would put two opaque 1px strokes on the same pixel.
+ parts.append(_tick(y, value, unit, decimals, rule=bool(k)))
+ zero = _coord(_PAD_T + plot_h * (1 - (0.0 - bottom) / span))
+ parts.append(f'')
return "".join(parts)
@@ -79,14 +141,30 @@ def line_chart(chart_id: str, labels: list[str], values: list[float], unit: str,
return '
no data yet
'
plot_h = _H - _PAD_T - _PAD_B
plot_w = _W - _PAD_L - _PAD_R
- top = _nice_top(max(values))
+ # The domain must contain zero AND every value: hours_saved goes negative in a
+ # rejection-heavy week (docs/metrics.md — a rejected run earns −m minutes), and
+ # a domain floored at zero maps that week far below the viewBox, where the
+ # browser clips it and the card silently hides the bad news. Non-negative data
+ # keeps bottom=0 and step=top/3, i.e. exactly the previous framing.
+ hi, lo = max(max(values), 0.0), min(min(values), 0.0)
+ if lo == 0.0:
+ top, bottom = _nice_top(hi), 0.0
+ step = top / 3
+ else:
+ # Snap both ends to multiples of a nice step so zero is itself a gridline.
+ step = _nice_step(hi - lo)
+ top, bottom = math.ceil(hi / step) * step, math.floor(lo / step) * step
+ span = (top - bottom) or 1.0
n = len(values)
xs = [_PAD_L + plot_w * (i + 0.5) / n for i in range(n)]
- ys = [_PAD_T + plot_h * (1 - v / top) for v in values]
+ ys = [_PAD_T + plot_h * (1 - (v - bottom) / span) for v in values]
baseline_y = _PAD_T + plot_h
+ zero_y = _coord(_PAD_T + plot_h * (1 - (0.0 - bottom) / span))
line_path = "M" + " L".join(f"{x:.1f} {y:.1f}" for x, y in zip(xs, ys, strict=True))
- area_path = f"{line_path} L{xs[-1]:.1f} {baseline_y} L{xs[0]:.1f} {baseline_y} Z"
+ # The area washes back to zero, not to the frame floor, so a negative week
+ # reads as a dip below the axis instead of a full-height fill.
+ area_path = f"{line_path} L{xs[-1]:.1f} {zero_y} L{xs[0]:.1f} {zero_y} Z"
hover = []
slot = plot_w / n
@@ -100,7 +178,7 @@ def line_chart(chart_id: str, labels: list[str], values: list[float], unit: str,
end_label = f"{_fmt(values[-1], 1)}{unit}"
end_x = min(xs[-1] + 8, _W - _PAD_R - 4)
return f"""