dataface 0.3.2.dev22__py3-none-any.whl → 0.3.2.dev111__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dataface/__init__.py +70 -23
  2. dataface/agent_api/__init__.py +162 -68
  3. dataface/agent_api/_paths.py +9 -3
  4. dataface/agent_api/docs/yaml-reference.md +240 -106
  5. dataface/agent_api/init.py +1 -1
  6. dataface/agent_api/project_session.py +2 -1
  7. dataface/agent_api/serve.py +115 -0
  8. dataface/ai/agent.py +28 -2
  9. dataface/cli/_project.py +100 -1
  10. dataface/cli/commands/board_artifact.py +5 -2
  11. dataface/cli/commands/describe.py +7 -3
  12. dataface/cli/commands/docs.py +12 -3
  13. dataface/cli/commands/init.py +7 -14
  14. dataface/cli/commands/inspect.py +6 -1
  15. dataface/cli/commands/mcp_init.py +23 -43
  16. dataface/cli/commands/migrate.py +8 -3
  17. dataface/cli/commands/query.py +11 -8
  18. dataface/cli/commands/render.py +20 -9
  19. dataface/cli/commands/search.py +8 -2
  20. dataface/cli/commands/serve.py +26 -76
  21. dataface/cli/commands/skills_init.py +2 -19
  22. dataface/cli/commands/validate.py +7 -2
  23. dataface/cli/filesystem_project.py +5 -4
  24. dataface/cli/main.py +2 -19
  25. dataface/core/__init__.py +73 -28
  26. dataface/core/{compile/colors.py → colors.py} +20 -5
  27. dataface/core/compile/compiler.py +97 -34
  28. dataface/core/compile/data_table.py +7 -2
  29. dataface/core/compile/enrich.py +18 -44
  30. dataface/core/compile/face_warnings.py +46 -8
  31. dataface/core/compile/format.py +47 -1
  32. dataface/core/compile/inherit_graph.py +3 -3
  33. dataface/core/compile/inherit_registry.yaml +1 -1
  34. dataface/core/compile/migrations.py +112 -9
  35. dataface/core/compile/models/chart/normalized/kpi.py +0 -4
  36. dataface/core/compile/models/chart/resolved/_base.py +28 -10
  37. dataface/core/compile/models/chart/resolved/callout.py +2 -3
  38. dataface/core/compile/models/chart/resolved/kpi.py +7 -3
  39. dataface/core/compile/models/chart/resolved/table.py +16 -2
  40. dataface/core/compile/models/markers.py +1 -1
  41. dataface/core/compile/models/primitives.py +175 -2
  42. dataface/core/compile/models/source.py +5 -5
  43. dataface/core/compile/models/style/authored/__init__.py +32 -8
  44. dataface/core/compile/models/style/authored/_base.py +126 -69
  45. dataface/core/compile/models/style/authored/table.py +61 -3
  46. dataface/core/compile/models/style/resolved/__init__.py +12 -2
  47. dataface/core/compile/models/style/resolved/_base.py +142 -57
  48. dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
  49. dataface/core/compile/models/style/resolved/_cascade.py +141 -54
  50. dataface/core/compile/models/style/resolved/area.py +1 -1
  51. dataface/core/compile/models/style/resolved/bar.py +1 -1
  52. dataface/core/compile/models/style/resolved/line.py +1 -1
  53. dataface/core/compile/models/style/resolved/scatter.py +3 -2
  54. dataface/core/compile/models/style/theme/__init__.py +36 -14
  55. dataface/core/compile/models/style/theme/_chart_base.py +17 -9
  56. dataface/core/compile/models/style/theme/axis.py +432 -312
  57. dataface/core/compile/models/style/theme/charts.py +10 -6
  58. dataface/core/compile/models/style/theme/kpi.py +2 -1
  59. dataface/core/compile/models/style/theme/page.py +5 -2
  60. dataface/core/compile/models/style/theme/table.py +2 -2
  61. dataface/core/compile/models/vega_lite/config.py +1 -1
  62. dataface/core/compile/normalize_charts.py +6 -2
  63. dataface/core/compile/normalize_queries.py +21 -7
  64. dataface/core/compile/resolve/__init__.py +2 -0
  65. dataface/core/compile/resolve/_axes.py +147 -44
  66. dataface/core/compile/resolve/_channels.py +1 -1
  67. dataface/core/compile/resolve/_dispatch.py +1 -1
  68. dataface/core/compile/resolve/_domain.py +49 -32
  69. dataface/core/compile/resolve/_kwargs.py +39 -12
  70. dataface/core/compile/resolve/_marks.py +8 -3
  71. dataface/core/compile/resolve/_palette.py +7 -3
  72. dataface/core/compile/resolve/_table.py +124 -33
  73. dataface/core/compile/resolve/area.py +48 -18
  74. dataface/core/compile/resolve/bar.py +245 -64
  75. dataface/core/compile/resolve/geo.py +10 -3
  76. dataface/core/compile/resolve/heatmap.py +16 -8
  77. dataface/core/compile/resolve/line.py +35 -12
  78. dataface/core/compile/resolve/pie.py +4 -1
  79. dataface/core/compile/resolve/pie_attachment.py +1 -1
  80. dataface/core/compile/resolve/scatter.py +25 -13
  81. dataface/core/compile/resolve/simple.py +25 -2
  82. dataface/core/compile/schema_renderers/prompt.py +1 -1
  83. dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
  84. dataface/core/compile/source_map.py +211 -28
  85. dataface/core/compile/style_cascade.py +126 -55
  86. dataface/core/compile/yaml_error_formatter.py +1 -1
  87. dataface/core/connections.py +10 -3
  88. dataface/core/dashboard.py +24 -18
  89. dataface/core/defaults/themes/_base.yaml +49 -39
  90. dataface/core/defaults/themes/cream.yaml +4 -4
  91. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
  92. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
  93. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
  94. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
  95. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
  96. dataface/core/defaults/themes/editorial.yaml +6 -6
  97. dataface/core/defaults/themes/neon.yaml +15 -8
  98. dataface/core/defaults/themes/plain.yaml +6 -4
  99. dataface/core/defaults/themes/stark.yaml +16 -7
  100. dataface/core/defaults/themes/vivid.yaml +15 -8
  101. dataface/core/diagnostics/__init__.py +2 -2
  102. dataface/core/diagnostics/codes_compile.py +13 -31
  103. dataface/core/diagnostics/codes_execute.py +79 -0
  104. dataface/core/diagnostics/codes_render.py +87 -28
  105. dataface/core/diagnostics/diagnostic.py +30 -0
  106. dataface/core/diagnostics/hints.py +19 -0
  107. dataface/core/dialects/base.py +17 -0
  108. dataface/core/dialects/postgres.py +14 -0
  109. dataface/core/dialects/redshift.py +5 -0
  110. dataface/core/execute/__init__.py +59 -14
  111. dataface/core/execute/adapters/dbt_adapter.py +4 -1
  112. dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
  113. dataface/core/execute/adapters/dbt_utils.py +84 -20
  114. dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
  115. dataface/core/execute/adapters/sql_adapter.py +72 -4
  116. dataface/core/execute/dbt_jinja.py +65 -10
  117. dataface/core/execute/executor.py +69 -16
  118. dataface/core/font_measure.py +28 -0
  119. dataface/core/fonts.py +8 -5
  120. dataface/core/inspect/__init__.py +46 -6
  121. dataface/core/project.py +13 -1
  122. dataface/core/render/chart/data_table_attachment.py +14 -12
  123. dataface/core/render/chart/emitters/_cartesian.py +21 -18
  124. dataface/core/render/chart/emitters/_channels.py +56 -8
  125. dataface/core/render/chart/emitters/_label_overlap.py +18 -15
  126. dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
  127. dataface/core/render/chart/emitters/area.py +3 -3
  128. dataface/core/render/chart/emitters/bar.py +60 -35
  129. dataface/core/render/chart/emitters/geo.py +8 -13
  130. dataface/core/render/chart/emitters/heatmap.py +20 -26
  131. dataface/core/render/chart/emitters/line.py +2 -2
  132. dataface/core/render/chart/emitters/scatter.py +7 -30
  133. dataface/core/render/chart/features/baseline.py +22 -9
  134. dataface/core/render/chart/features/endpoint_labels.py +55 -32
  135. dataface/core/render/chart/features/mirror_axis.py +12 -9
  136. dataface/core/render/chart/features/value_labels.py +1 -1
  137. dataface/core/render/chart/kpi.py +11 -90
  138. dataface/core/render/chart/presentation.py +6 -8
  139. dataface/core/render/chart/rendering.py +3 -12
  140. dataface/core/render/chart/session.py +10 -0
  141. dataface/core/render/chart/spec.py +3 -0
  142. dataface/core/render/chart/spec_builders.py +18 -18
  143. dataface/core/render/chart/table.py +81 -36
  144. dataface/core/render/chart/table_support.py +2 -53
  145. dataface/core/render/chart/time_unit_detect.py +3 -2
  146. dataface/core/render/chart/translate.py +82 -44
  147. dataface/core/render/chart/type_inference.py +45 -25
  148. dataface/core/render/chart/validation.py +91 -0
  149. dataface/core/render/chart/vega_lite.py +30 -11
  150. dataface/core/render/chart/vl_field_maps.py +111 -76
  151. dataface/core/render/converters/chart.py +16 -6
  152. dataface/core/render/faces.py +3 -2
  153. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
  154. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
  155. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
  156. dataface/core/render/fonts/_font_face.css +10 -0
  157. dataface/core/render/layout_sizing.py +267 -238
  158. dataface/core/render/numeral_expr.py +80 -0
  159. dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
  160. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
  161. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
  162. dataface/core/render/warnings/palette_unsupported.py +6 -0
  163. dataface/core/render/warnings/pie_dominant_segment.py +1 -0
  164. dataface/core/render/warnings/pie_too_many_segments.py +1 -0
  165. dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
  166. dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
  167. dataface/core/render/warnings/redundant_encoding.py +20 -1
  168. dataface/core/render/warnings/registry.py +7 -3
  169. dataface/core/render/warnings/temporal_single_point.py +1 -0
  170. dataface/core/render/warnings/too_many_color_categories.py +1 -0
  171. dataface/core/render/warnings/too_many_x_categories.py +18 -2
  172. dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
  173. dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
  174. dataface/core/render_format.py +18 -0
  175. dataface/core/text/format_d3.py +56 -0
  176. dataface/core/text/numeral_scale.py +218 -0
  177. dataface/core/utils.py +103 -10
  178. dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
  179. dataface/data/schemas/yaml/0.3.2.json +1 -0
  180. dataface/data/schemas/yaml/manifest.json +7 -0
  181. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
  182. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
  183. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
  184. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
  185. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/licenses/LICENSE +0 -0
@@ -22,9 +22,12 @@ from dataface.core.compile.models.style.resolved import (
22
22
  build_resolved_axis,
23
23
  )
24
24
  from dataface.core.compile.resolve._axes import (
25
+ _author_asked_for_endpoint_labels,
26
+ _author_hid_legend,
25
27
  _bake_ay_orient,
26
28
  _bake_cartesian_axes,
27
29
  _edge_or_none,
30
+ _endpoint_labels_off_for_layers,
28
31
  _extract_axis_overrides,
29
32
  _series_label_layout_for_width,
30
33
  _suppress_legend_for_endpoint_labels,
@@ -204,17 +207,22 @@ def _resolve_area(
204
207
  board_rcs = board_style.charts
205
208
  chart_rcs = build_resolved_style(board_style, normalized)
206
209
  area = chart_rcs.area
207
- endpoint_labels, show_top_legend = _series_label_layout_for_width(
208
- area.endpoint_labels, width
209
- )
210
210
  primary = _with_color_tokens(normalized.style, board_style)
211
+ endpoint_labels, tiny_top_legend = _series_label_layout_for_width(
212
+ _endpoint_labels_off_for_layers(
213
+ area.endpoint_labels,
214
+ normalized,
215
+ _author_asked_for_endpoint_labels(normalized.style),
216
+ ),
217
+ width,
218
+ )
211
219
  channels = _channels_for(normalized, data)
212
220
  wide_measure_series = isinstance(normalized.y, list)
213
221
  x_ch_type = _classify_to_channel_type(normalized.x, data, is_dimension=True)
214
222
  resolved_stack = normalized.stack if normalized.stack is not None else area.stack
215
223
  _validate_area_encoding(normalized, data, x_ch_type, resolved_stack)
216
224
  ax_ov = _extract_axis_overrides(primary, normalized.x_label, normalized.y_label)
217
- ax_merged, ay_merged = _bake_cartesian_axes(
225
+ ax_merged, ay_merged, ax_band_position, ay_band_position = _bake_cartesian_axes(
218
226
  chart_rcs,
219
227
  normalized,
220
228
  "area",
@@ -232,7 +240,8 @@ def _resolve_area(
232
240
  endpoint_labels.visible,
233
241
  wide_measure_series=wide_measure_series,
234
242
  )
235
- authored_y_domain = ay_merged.scale.domain if ay_merged.scale is not None else None
243
+ _ay_cont_area = ay_merged.scale.continuous if ay_merged.scale is not None else None
244
+ authored_y_domain = _ay_cont_area.domain if _ay_cont_area is not None else None
236
245
  stable_extent = _eligible_stable_extent(ay_merged, stable_y_domain)
237
246
  _area_axis_values = list(stable_extent) if stable_extent else _area_floats
238
247
  if y_field_area:
@@ -247,10 +256,15 @@ def _resolve_area(
247
256
  # The layer ambiguity check must see the pre-workaround domain state: it is
248
257
  # about an authored chart-level domain, not the synthetic area-log domain.
249
258
  _reject_non_positive_log_scale_data(normalized.id, ay_merged, _log_y_fields, data)
259
+ _ay_cont_log = ay_merged.scale.continuous if ay_merged.scale is not None else None
250
260
  if (
251
- ay_merged.scale is not None
252
- and ay_merged.scale.type == "log"
253
- and resolved_stack not in (None, "none")
261
+ _ay_cont_log is not None
262
+ and _ay_cont_log.type == "log"
263
+ and resolved_stack
264
+ not in (
265
+ None,
266
+ "none",
267
+ )
254
268
  ):
255
269
  raise CompilationError.from_code(
256
270
  ERR_AREA_STACKED_LOG_SCALE_NOT_SUPPORTED,
@@ -259,9 +273,9 @@ def _resolve_area(
259
273
  )
260
274
  if (
261
275
  _log_y_fields
262
- and ay_merged.scale is not None
263
- and ay_merged.scale.type == "log"
264
- and ay_merged.scale.domain is None
276
+ and _ay_cont_log is not None
277
+ and _ay_cont_log.type == "log"
278
+ and _ay_cont_log.domain is None
265
279
  ):
266
280
  # Vega-Lite's automatic domain inference degenerates for an area mark
267
281
  # on a log scale with no explicit domain (verified: renders as a flat
@@ -275,11 +289,14 @@ def _resolve_area(
275
289
  _log_y_floats = [
276
290
  v for field in _log_y_fields for v in numeric_column_values(data, field)
277
291
  ]
278
- if _log_y_floats:
292
+ if _log_y_floats and ay_merged.scale is not None:
293
+ _new_cont = _ay_cont_log.model_copy(
294
+ update={"domain": [min(_log_y_floats), max(_log_y_floats)]}
295
+ )
279
296
  ay_merged = ay_merged.model_copy(
280
297
  update={
281
298
  "scale": ay_merged.scale.model_copy(
282
- update={"domain": [min(_log_y_floats), max(_log_y_floats)]}
299
+ update={"continuous": _new_cont}
283
300
  )
284
301
  }
285
302
  )
@@ -341,9 +358,10 @@ def _resolve_area(
341
358
  area_domain_max = area_ticks.domain_max if _non_stacked_area else None
342
359
  area_domain_min = area_ticks.domain_min if _non_stacked_area else None
343
360
  # Area's x is always a bottom-orient temporal/ordinal axis — no left/right edge.
344
- ax = build_resolved_axis(ax_merged, edge=None)
361
+ ax = build_resolved_axis(ax_merged, band_position=ax_band_position, edge=None)
345
362
  ay = build_resolved_axis(
346
363
  ay_merged,
364
+ band_position=ay_band_position,
347
365
  edge=_edge_or_none(ay_merged.position),
348
366
  tick_values=area_ticks.ticks,
349
367
  domain_max=area_domain_max if area_domain_max is not None else ...,
@@ -382,7 +400,9 @@ def _resolve_area(
382
400
  if line_overrides:
383
401
  line_mark_merged = line_mark_merged.model_copy(update=line_overrides)
384
402
  line_mark_merged = line_mark_merged.model_copy(
385
- update={"labels": _label_format_fallback(line_mark_merged.labels, ay.format)}
403
+ update={
404
+ "labels": _label_format_fallback(line_mark_merged.labels, ay.labels.format)
405
+ }
386
406
  )
387
407
  # Density-adaptive stroke for area's top-edge line. For stacked charts the
388
408
  # effective stroke is on the stacked perimeter (already merged above into
@@ -440,7 +460,7 @@ def _resolve_area(
440
460
  # visually track the line width).
441
461
  resolved_area_mark = _build_resolved_area_mark(area_mark_merged)
442
462
  resolved_line_mark = _build_resolved_area_line(line_mark_merged)
443
- area_style_tail = _cartesian_style_tail(board_rcs, board_style, ax, ay)
463
+ area_style_tail = _cartesian_style_tail(board_rcs, ax, ay)
444
464
  _area_fmt = _measure_tooltip_format(normalized, primary, board_rcs)
445
465
  if _area_fmt:
446
466
  area_style_tail["tooltip_format"] = _area_fmt
@@ -459,12 +479,22 @@ def _resolve_area(
459
479
  _effective_palette(board_rcs, primary),
460
480
  requested_alias_palette=_effective_requested_alias_palette(board_rcs, primary),
461
481
  automatic_link_candidate=automatic_link_candidate,
482
+ layout_padding=area.padding,
462
483
  suppress_legend=_suppress_legend_for_endpoint_labels(
463
484
  channels,
464
485
  endpoint_labels.visible,
486
+ bool(normalized.layers),
465
487
  wide_measure_series=wide_measure_series,
466
488
  ),
467
- show_top_legend=show_top_legend,
489
+ top_legend=(
490
+ "off"
491
+ if _author_hid_legend(primary)
492
+ else (
493
+ "compact"
494
+ if tiny_top_legend
495
+ else ("row" if normalized.layers else "off")
496
+ )
497
+ ),
468
498
  )
469
499
  resolved_layers = _resolve_layer_list(
470
500
  normalized.layers,
@@ -478,7 +508,7 @@ def _resolve_area(
478
508
  _check_layers_y_domain(normalized.id, resolved_layers, authored_y_domain)
479
509
  return ResolvedAreaChart(
480
510
  **bkw,
481
- **_cartesian_kwargs(normalized, board_rcs, variables),
511
+ **_cartesian_kwargs(normalized, chart_rcs, variables),
482
512
  chart_type="area",
483
513
  stack=resolved_stack,
484
514
  style=ResolvedAreaStyle(
@@ -7,25 +7,33 @@ from typing import Any
7
7
 
8
8
  from dataface.core.compile.errors import CompilationError
9
9
  from dataface.core.compile.merge import merge_onto_base
10
+ from dataface.core.compile.models.chart.authored._data_table import (
11
+ ChartDataTablePerSeries,
12
+ )
10
13
  from dataface.core.compile.models.chart.normalized import (
11
14
  BarChart,
12
15
  )
13
16
  from dataface.core.compile.models.chart.resolved import (
14
17
  ResolvedBarChart,
15
18
  )
19
+ from dataface.core.compile.models.style.authored import EndpointLabelsConfig
16
20
  from dataface.core.compile.models.style.resolved import (
17
21
  ResolvedBarStyle,
18
22
  ResolvedStyle,
19
23
  build_resolved_axis,
20
24
  )
21
25
  from dataface.core.compile.models.style.theme import (
22
- ScaleStyle,
26
+ BaseScaleStyle,
27
+ ScaleContinuousStyle,
23
28
  )
24
29
  from dataface.core.compile.resolve._axes import (
30
+ _author_asked_for_endpoint_labels,
31
+ _author_hid_legend,
25
32
  _bake_ay_orient,
26
33
  _bake_ay_position_left,
27
34
  _bake_cartesian_axes,
28
35
  _edge_or_none,
36
+ _endpoint_labels_off_for_layers,
29
37
  _extract_axis_overrides,
30
38
  _series_label_layout_for_width,
31
39
  _suppress_legend_for_endpoint_labels,
@@ -86,6 +94,10 @@ from dataface.core.diagnostics.codes_compile import (
86
94
  ERR_BAR_LOG_SCALE_NOT_SUPPORTED,
87
95
  ERR_BAR_Y_NOT_NUMERIC,
88
96
  )
97
+ from dataface.core.utils import (
98
+ numeric_column_values,
99
+ stacked_x_domain_order,
100
+ )
89
101
 
90
102
  __all__ = [
91
103
  "_resolve_bar",
@@ -93,6 +105,125 @@ __all__ = [
93
105
  ]
94
106
 
95
107
 
108
+ def _bar_endpoint_labels_for_stack(
109
+ endpoint_labels: EndpointLabelsConfig,
110
+ normalized: BarChart,
111
+ data: ChartRows,
112
+ stack_mode: str,
113
+ horizontal: bool,
114
+ author_opted_in: bool,
115
+ ) -> EndpointLabelsConfig:
116
+ """Direct labelling is the default; the disqualifiers below turn it back off.
117
+
118
+ Every disqualifier here steers the *default* away from a shape it would
119
+ render badly or not at all. An author who wrote
120
+ ``style.endpoint_labels.visible: true`` on this chart has said they want
121
+ the rail on that shape, so ``author_opted_in`` returns the cascaded value
122
+ untouched: grouped bars label at their bar tops as they always have, and
123
+ the shapes render refuses (a negative measure, a sorted or centre-stacked
124
+ horizontal rail, ``multiples:``) raise there with a message naming the
125
+ conflict. Quietly dropping the rail instead would be neither.
126
+ """
127
+ if author_opted_in:
128
+ return endpoint_labels
129
+ if normalized.data_table is not None and any(
130
+ isinstance(entry, ChartDataTablePerSeries) and not entry.by_measure
131
+ for entry in normalized.data_table.entries
132
+ ):
133
+ # A per-series data table already prints one row per series, labelled
134
+ # in that series' own ink — the rail would name them a second time,
135
+ # and it costs the plot the height and the axis side it needs.
136
+ return endpoint_labels.model_copy(update={"visible": False})
137
+ if stack_mode == "none":
138
+ # Grouped: every series' last bar rises from the same baseline to a
139
+ # similar height, so the collision cascade squashes the labels
140
+ # together. A legend is the honest treatment.
141
+ return endpoint_labels.model_copy(update={"visible": False})
142
+ if normalized.multiples is not None:
143
+ # Each small multiple is its own panel; the rail has no single panel
144
+ # to sit beside, and FacetFeature refuses the composition outright.
145
+ return endpoint_labels.model_copy(update={"visible": False})
146
+ y_fields = (
147
+ (normalized.y,)
148
+ if isinstance(normalized.y, str)
149
+ else tuple(normalized.y) if isinstance(normalized.y, list) else ()
150
+ )
151
+ # A negative (zero-crossing) measure breaks the cumulative-midpoint
152
+ # computation; the render layer raises rather than mislabel. Stack mode
153
+ # zero/normalize/center all reach that raise, so this check runs
154
+ # regardless of which one resolved_stack picked.
155
+ if any(v < 0 for v in _numeric_y_values(data, y_fields)):
156
+ return endpoint_labels.model_copy(update={"visible": False})
157
+ # author_opted_in is False by construction here — the opt-in returned above.
158
+ endpoint_labels = _endpoint_labels_off_for_layers(
159
+ endpoint_labels, normalized, author_opted_in
160
+ )
161
+ if not endpoint_labels.visible:
162
+ return endpoint_labels
163
+ if normalized.sort is not None and not numeric_column_values(
164
+ data, normalized.sort.by
165
+ ):
166
+ # Both rails reproduce Vega-Lite's domain order by totalling the sort
167
+ # column per category. On a non-numeric column VL concatenates the
168
+ # strings instead, an order this cannot mirror — so the rail would
169
+ # anchor on a row VL does not draw on top.
170
+ return endpoint_labels.model_copy(update={"visible": False})
171
+ if horizontal and stack_mode == "center":
172
+ # The horizontal rail anchors on the cumulative (0..Σ) axis, which the
173
+ # diverging center-stack domain doesn't have.
174
+ return endpoint_labels.model_copy(update={"visible": False})
175
+ if not _every_series_reaches_the_last_column(
176
+ normalized, data, y_fields, horizontal
177
+ ):
178
+ return endpoint_labels.model_copy(update={"visible": False})
179
+ return endpoint_labels
180
+
181
+
182
+ def _every_series_reaches_the_last_column(
183
+ normalized: BarChart,
184
+ data: ChartRows,
185
+ y_fields: tuple[str, ...],
186
+ horizontal: bool,
187
+ ) -> bool:
188
+ """True when the final x column carries a segment for every series.
189
+
190
+ A stacked bar's labels anchor to that column's segment midpoints, so a
191
+ series absent from it has nothing to anchor to and goes unnamed while its
192
+ colour stays in the chart — the reader counts more colours than names.
193
+ Two shapes hit this: a trailing null, and ``color:`` naming the same field
194
+ as ``x:``, which gives every series a single distinct column.
195
+ """
196
+ color = normalized.color
197
+ x = normalized.x
198
+ if not isinstance(color, str) or not isinstance(x, str):
199
+ return True
200
+ # Each orientation anchors its rail on a different end of the rendered x
201
+ # domain, so ask the same question the rail will: the vertical midpoint
202
+ # path takes the last column (_stacked_midpoints), the horizontal rail the
203
+ # first row (_cumulative_midpoints), both over the order stacked_x_domain_order
204
+ # computes in render/chart/features/endpoint_labels.py — which an authored
205
+ # `sort:` moves. A wholly null anchor row still owns the rail.
206
+ sort = normalized.sort
207
+ domain = stacked_x_domain_order(
208
+ data, x, sort.by if sort else "", bool(sort and sort.order == "desc")
209
+ )
210
+ if not domain:
211
+ return True
212
+ last_x = domain[0] if horizontal else domain[-1]
213
+ every_series: set[str] = set()
214
+ drawn_at_last: set[str] = set()
215
+ for row in data:
216
+ series = row.get(color)
217
+ if series is None:
218
+ continue
219
+ every_series.add(series)
220
+ if row.get(x) == last_x and any(
221
+ row.get(field) is not None for field in y_fields
222
+ ):
223
+ drawn_at_last.add(series)
224
+ return drawn_at_last == every_series
225
+
226
+
96
227
  def _resolve_bar(
97
228
  normalized: BarChart,
98
229
  data: ChartRows,
@@ -106,9 +237,10 @@ def _resolve_bar(
106
237
  board_rcs = board_style.charts
107
238
  chart_rcs = build_resolved_style(board_style, normalized)
108
239
  bar = chart_rcs.bar
109
- endpoint_labels, show_top_legend = _series_label_layout_for_width(
110
- bar.endpoint_labels, width
111
- )
240
+ # Flat chart.stack overrides style.bar.stack; fall through to the merged bar
241
+ # style (which already applied style.bar.stack → board theme cascade) so that
242
+ # per-chart style overrides are respected even when chart.stack is None.
243
+ resolved_stack = normalized.stack if normalized.stack is not None else bar.stack
112
244
  primary = _with_color_tokens(normalized.style, board_style)
113
245
  channels = _channels_for(normalized, data)
114
246
  x_ch_type = _classify_to_channel_type(normalized.x, data, is_dimension=True)
@@ -130,7 +262,7 @@ def _resolve_bar(
130
262
  hint=_bar_y_hint,
131
263
  )
132
264
  ax_ov = _extract_axis_overrides(primary, normalized.x_label, normalized.y_label)
133
- ax_merged, ay_merged = _bake_cartesian_axes(
265
+ ax_merged, ay_merged, ax_band_position, ay_band_position = _bake_cartesian_axes(
134
266
  chart_rcs,
135
267
  normalized,
136
268
  "bar",
@@ -146,56 +278,83 @@ def _resolve_bar(
146
278
  # both for the categorical axis's edge below and the chart's own
147
279
  # resolved `orientation` field at the bottom of this function.
148
280
  orientation = _bar_orientation(normalized, data, ax_merged.time_unit is not None)
149
- ay_merged = _bake_ay_orient(ay_merged, channels, endpoint_labels.visible)
150
- # Bar's categorical axis (axis_x) only has a left/right edge when the
151
- # chart is horizontal — it then renders on VL's y channel at
152
- # ay.categorical_orient (see emitters/bar.py's own-orient swap). A
153
- # vertical bar's x stays bottom-orient — no edge.
154
- x_edge = (
155
- _edge_or_none(ay_merged.categorical_orient)
156
- if orientation == "horizontal"
157
- else None
281
+ # Resolved after orientation: two of the disqualifiers below are specific to
282
+ # the horizontal rail, and the last-column check anchors differently per
283
+ # orientation.
284
+ endpoint_labels, tiny_top_legend = _series_label_layout_for_width(
285
+ _bar_endpoint_labels_for_stack(
286
+ bar.endpoint_labels,
287
+ normalized,
288
+ data,
289
+ resolved_stack,
290
+ orientation == "horizontal",
291
+ _author_asked_for_endpoint_labels(normalized.style),
292
+ ),
293
+ width,
158
294
  )
159
- # Propagate chart-level scale.band_padding_inner to ax.scale so the emitter
160
- # can read ax.scale.band_padding_inner without re-examining primary.scale.
161
- # (In V1 this flowed via effective.scale → SCALE_FIELD_MAP; V2 reads ax.scale.)
162
- _chart_scale = primary.scale if primary is not None else None
163
- if _chart_scale is not None and _chart_scale.band_padding_inner is not None:
164
- existing_ax_scale = (
165
- ax_merged.scale if ax_merged.scale is not None else ScaleStyle()
166
- )
167
- ax_merged = ax_merged.model_copy(
168
- update={
169
- "scale": existing_ax_scale.model_copy(
170
- update={"band_padding_inner": _chart_scale.band_padding_inner}
171
- )
172
- }
173
- )
174
- # Propagate bar.scale.zero (theme→face cascade result) to ay.scale so the
175
- # emitter's ay_scale_zero check reflects an authored explicit zero override.
295
+ if orientation == "horizontal":
296
+ # For horizontal bar, ay (the semantic measure axis) maps to VL x,
297
+ # which has no left/right orient — _emit_horizontal pops it entirely.
298
+ # ay.position's only remaining role is supplying the swapped
299
+ # categorical axis's (VL y) orient in emitters/bar.py. The deleted
300
+ # categorical_orient field always defaulted "left" there, statically —
301
+ # the horizontal rail sits above the plot (top-row), never left/right,
302
+ # so it never competes for this axis's side the way the vertical
303
+ # endpoint-label rail does. Preserve the static default via
304
+ # _bake_ay_position_left rather than _bake_ay_orient's
305
+ # endpoint-label-aware auto-resolution.
306
+ ay_merged = _bake_ay_position_left(ay_merged)
307
+ else:
308
+ ay_merged = _bake_ay_orient(ay_merged, channels, endpoint_labels.visible)
309
+ # Bar's categorical axis (axis_x) only has a left/right edge when the
310
+ # chart is horizontal — it then renders on VL's y channel at ay.position
311
+ # (see emitters/bar.py's own-orient swap; categorical_orient was deleted
312
+ # 2026-08 — position now serves this role too). A vertical bar's x stays
313
+ # bottom-orient — no edge.
314
+ x_edge = _edge_or_none(ay_merged.position) if orientation == "horizontal" else None
315
+ # Propagate bar.scale.continuous.zero (theme→face cascade result) to ay.scale
316
+ # so the emitter's ay_scale_zero check reflects an authored explicit zero override.
176
317
  # Only concrete bools are propagated; "auto"/None means run the picker default
177
318
  # (non-stacked bars always anchor at zero by default).
178
319
  _bar_scale = bar.scale
179
- if _bar_scale is not None and isinstance(_bar_scale.zero, bool):
180
- existing_ay_scale = (
181
- ay_merged.scale if ay_merged.scale is not None else ScaleStyle()
182
- )
183
- ay_merged = ay_merged.model_copy(
184
- update={
185
- "scale": existing_ay_scale.model_copy(update={"zero": _bar_scale.zero})
186
- }
187
- )
188
- if ay_merged.scale is not None and ay_merged.scale.type == "log":
320
+ _bar_zero = (
321
+ _bar_scale.continuous.zero
322
+ if _bar_scale is not None and _bar_scale.continuous is not None
323
+ else None
324
+ )
325
+ if isinstance(_bar_zero, bool):
326
+ existing_ay_scale = ay_merged.scale
327
+ if existing_ay_scale is not None:
328
+ old_cont = existing_ay_scale.continuous
329
+ new_cont = (
330
+ old_cont.model_copy(update={"zero": _bar_zero})
331
+ if old_cont is not None
332
+ else ScaleContinuousStyle(zero=_bar_zero)
333
+ )
334
+ ay_merged = ay_merged.model_copy(
335
+ update={
336
+ "scale": existing_ay_scale.model_copy(
337
+ update={"continuous": new_cont}
338
+ )
339
+ }
340
+ )
341
+ else:
342
+ ay_merged = ay_merged.model_copy(
343
+ update={
344
+ "scale": BaseScaleStyle(
345
+ continuous=ScaleContinuousStyle(zero=_bar_zero)
346
+ )
347
+ }
348
+ )
349
+ _ay_cont_log = ay_merged.scale.continuous if ay_merged.scale is not None else None
350
+ if _ay_cont_log is not None and _ay_cont_log.type == "log":
189
351
  raise CompilationError.from_code(
190
352
  ERR_BAR_LOG_SCALE_NOT_SUPPORTED,
191
353
  chart_id=normalized.id,
192
354
  )
193
- # Flat chart.stack overrides style.bar.stack; fall through to the merged bar
194
- # style (which already applied style.bar.stack → board theme cascade) so that
195
- # per-chart style overrides are respected even when chart.stack is None.
196
- resolved_stack = normalized.stack if normalized.stack is not None else bar.stack
197
355
  is_stacked = resolved_stack not in (None, "none")
198
- ay_scale_zero = ay_merged.scale.zero if ay_merged.scale is not None else None
356
+ _ay_cont_zero = ay_merged.scale.continuous if ay_merged.scale is not None else None
357
+ ay_scale_zero = _ay_cont_zero.zero if _ay_cont_zero is not None else None
199
358
  # Non-stacked bars anchor at zero by default unless scale.zero is explicitly False.
200
359
  # Stacked bars only anchor when scale.zero is explicitly True.
201
360
  bar_zero = (not is_stacked and ay_scale_zero is not False) or ay_scale_zero is True
@@ -209,7 +368,7 @@ def _resolve_bar(
209
368
  stable_extent = _eligible_stable_extent(
210
369
  ay_merged, stable_y_domain, stacked=is_stacked
211
370
  )
212
- authored_y_domain = ay_merged.scale.domain if ay_merged.scale is not None else None
371
+ authored_y_domain = _ay_cont_zero.domain if _ay_cont_zero is not None else None
213
372
  if resolved_stack == "normalize" or not y_fields:
214
373
  # normalize-stack domain is always [0,1]; no y_field means no ticks.
215
374
  tick_values: tuple[float, ...] = ()
@@ -258,15 +417,16 @@ def _resolve_bar(
258
417
  stacked_domain_max = apply_headroom(
259
418
  _raw_stacked_max, _axis_headroom(ay_merged)
260
419
  )
261
- ax = build_resolved_axis(ax_merged, edge=x_edge)
420
+ ax = build_resolved_axis(ax_merged, band_position=ax_band_position, edge=x_edge)
262
421
  ay = build_resolved_axis(
263
422
  ay_merged,
423
+ band_position=ay_band_position,
264
424
  edge=_edge_or_none(ay_merged.position),
265
425
  tick_values=tick_values,
266
426
  domain_max=bar_domain_max if bar_domain_max is not None else ...,
267
427
  domain_min=bar_domain_min if bar_domain_min is not None else ...,
268
428
  )
269
- style_tail = _cartesian_style_tail(board_rcs, board_style, ax, ay)
429
+ style_tail = _cartesian_style_tail(board_rcs, ax, ay)
270
430
  _bar_fmt = _measure_tooltip_format(normalized, primary, board_rcs)
271
431
  if _bar_fmt:
272
432
  style_tail["tooltip_format"] = _bar_fmt
@@ -285,10 +445,18 @@ def _resolve_bar(
285
445
  _effective_palette(board_rcs, primary),
286
446
  requested_alias_palette=_effective_requested_alias_palette(board_rcs, primary),
287
447
  automatic_link_candidate=automatic_link_candidate,
448
+ layout_padding=bar.padding,
288
449
  suppress_legend=_suppress_legend_for_endpoint_labels(
289
- channels, endpoint_labels.visible
450
+ channels, endpoint_labels.visible, bool(normalized.layers)
451
+ ),
452
+ # A grouped bar keeps its legend (see _bar_endpoint_labels_for_stack)
453
+ # and reads left to right, so the legend belongs above the plot in the
454
+ # same order as the bars within a group — not down a side rail.
455
+ top_legend=(
456
+ "off"
457
+ if _author_hid_legend(primary)
458
+ else "compact" if tiny_top_legend else ("row" if not is_stacked else "off")
290
459
  ),
291
- show_top_legend=show_top_legend,
292
460
  )
293
461
  resolved_layers = _resolve_layer_list(
294
462
  normalized.layers, board_style, "bar", bar, normalized.query_name, 0.0
@@ -299,19 +467,19 @@ def _resolve_bar(
299
467
  # label: if either format is None, inherit the resolved measure-axis format.
300
468
  raw_total = bar.marks.bar.total_label
301
469
  resolved_total = (
302
- raw_total.model_copy(update={"format": ay.format})
303
- if raw_total.format is None and ay.format is not None
470
+ raw_total.model_copy(update={"format": ay.labels.format})
471
+ if raw_total.format is None and ay.labels.format is not None
304
472
  else raw_total
305
473
  )
306
474
  bar_mark = bar.marks.bar.model_copy(
307
475
  update={
308
- "labels": _label_format_fallback(bar.marks.bar.labels, ay.format),
476
+ "labels": _label_format_fallback(bar.marks.bar.labels, ay.labels.format),
309
477
  "total_label": resolved_total,
310
478
  }
311
479
  )
312
480
  return ResolvedBarChart(
313
481
  **bkw,
314
- **_cartesian_kwargs(normalized, board_rcs, variables),
482
+ **_cartesian_kwargs(normalized, chart_rcs, variables),
315
483
  chart_type="bar",
316
484
  stack=resolved_stack,
317
485
  stacked_domain_max=stacked_domain_max,
@@ -351,22 +519,28 @@ def _resolve_histogram(
351
519
  histogram.marks.bar.border apply correctly.
352
520
  """
353
521
  board_rcs = board_style.charts
522
+ chart_rcs = build_resolved_style(board_style, normalized)
354
523
  primary = _with_color_tokens(normalized.style, board_style)
355
524
  hist = merge_onto_base(board_rcs.histogram, primary)
356
525
  channels = _channels_for(normalized, data)
357
526
  x_ch_type = _classify_to_channel_type(normalized.x, data, is_dimension=True)
358
527
  ax_ov = _extract_axis_overrides(primary, normalized.x_label, normalized.y_label)
359
- # board_rcs (not chart_rcs) suffices here: histogram never calls
360
- # build_resolved_style, and axis Layers 4-6 (chart-local overrides) reach
361
- # the cascade via ax_ov regardless of which rcs backs Layers 1-3.
362
- ax_merged, ay_merged = _bake_cartesian_axes(
528
+ # board_rcs (not chart_rcs) suffices for axis baking: axis Layers 4-6
529
+ # (chart-local overrides) reach the cascade via ax_ov regardless of which
530
+ # rcs backs Layers 1-3. chart_rcs is only needed below for the shared
531
+ # background/title-case kwargs, which are genuinely chart-local decisions.
532
+ ax_merged, ay_merged, ax_band_position, ay_band_position = _bake_cartesian_axes(
363
533
  board_rcs, normalized, "histogram", x_ch_type, "quantitative", ax_ov
364
534
  )
365
535
  ay_merged = _bake_ay_position_left(ay_merged)
366
536
  # Histogram's x is always a bottom-orient bin axis — no left/right edge.
367
- ax = build_resolved_axis(ax_merged, edge=None)
368
- ay = build_resolved_axis(ay_merged, edge=_edge_or_none(ay_merged.position))
369
- style_tail = _cartesian_style_tail(board_rcs, board_style, ax, ay)
537
+ ax = build_resolved_axis(ax_merged, band_position=ax_band_position, edge=None)
538
+ ay = build_resolved_axis(
539
+ ay_merged,
540
+ band_position=ay_band_position,
541
+ edge=_edge_or_none(ay_merged.position),
542
+ )
543
+ style_tail = _cartesian_style_tail(board_rcs, ax, ay)
370
544
  # histogram reuses ResolvedBarStyle; these bar-only fields are unread by
371
545
  # the histogram emit path (no stack, no endpoint labels, no grouped x offset).
372
546
  _s = normalized.style
@@ -387,8 +561,9 @@ def _resolve_histogram(
387
561
  board_rcs, primary
388
562
  ),
389
563
  automatic_link_candidate=automatic_link_candidate,
564
+ layout_padding=hist.padding,
390
565
  ),
391
- **_cartesian_kwargs(normalized, board_rcs, variables),
566
+ **_cartesian_kwargs(normalized, chart_rcs, variables),
392
567
  chart_type="histogram",
393
568
  stack="none",
394
569
  stacked_domain_max=None,
@@ -404,7 +579,13 @@ def _resolve_histogram(
404
579
  rhythm_slot=normalized.rhythm_slot,
405
580
  has_layers=bool(normalized.layers),
406
581
  ),
407
- endpoint_labels=board_rcs.bar.endpoint_labels,
582
+ # A histogram bins x and aggregates y to a count, so there is no
583
+ # per-row value for a rail to anchor to — it would place labels
584
+ # against a measure the chart never plots. Bar's slot supplies the
585
+ # rest of the style; this one field is off for the family.
586
+ endpoint_labels=board_rcs.bar.endpoint_labels.model_copy(
587
+ update={"visible": False}
588
+ ),
408
589
  label_usable_ratio=board_rcs.label_usable_ratio,
409
590
  title_font=_tf,
410
591
  **style_tail,