dataface 0.3.2.dev22__py3-none-any.whl → 0.3.2.dev150__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 (227) hide show
  1. dataface/DATAFACE_SYNTAX.md +15 -4
  2. dataface/__init__.py +70 -23
  3. dataface/agent_api/__init__.py +162 -68
  4. dataface/agent_api/_paths.py +9 -3
  5. dataface/agent_api/docs/yaml-reference.md +273 -239
  6. dataface/agent_api/init.py +1 -1
  7. dataface/agent_api/project_session.py +2 -1
  8. dataface/agent_api/serve.py +113 -0
  9. dataface/agent_api/validate.py +8 -0
  10. dataface/ai/agent.py +28 -2
  11. dataface/cli/_project.py +100 -1
  12. dataface/cli/commands/board_artifact.py +5 -2
  13. dataface/cli/commands/describe.py +7 -3
  14. dataface/cli/commands/docs.py +12 -3
  15. dataface/cli/commands/init.py +7 -14
  16. dataface/cli/commands/inspect.py +6 -1
  17. dataface/cli/commands/mcp_init.py +23 -43
  18. dataface/cli/commands/migrate.py +8 -3
  19. dataface/cli/commands/query.py +11 -8
  20. dataface/cli/commands/render.py +26 -9
  21. dataface/cli/commands/search.py +8 -2
  22. dataface/cli/commands/serve.py +25 -81
  23. dataface/cli/commands/skills_init.py +2 -19
  24. dataface/cli/commands/validate.py +7 -2
  25. dataface/cli/filesystem_project.py +5 -4
  26. dataface/cli/main.py +22 -33
  27. dataface/core/__init__.py +73 -28
  28. dataface/core/aliases.py +27 -0
  29. dataface/core/{compile/colors.py → colors.py} +20 -5
  30. dataface/core/compile/__init__.py +0 -8
  31. dataface/core/compile/channel.py +0 -13
  32. dataface/core/compile/compiler.py +161 -96
  33. dataface/core/compile/data_table.py +7 -2
  34. dataface/core/compile/enrich.py +18 -44
  35. dataface/core/compile/face_warnings.py +46 -8
  36. dataface/core/compile/format.py +47 -1
  37. dataface/core/compile/inherit_graph.py +3 -3
  38. dataface/core/compile/inherit_registry.yaml +1 -1
  39. dataface/core/compile/introspection.py +2 -13
  40. dataface/core/compile/migrations.py +112 -9
  41. dataface/core/compile/models/chart/authored/_base.py +1 -1
  42. dataface/core/compile/models/chart/normalized/__init__.py +2 -0
  43. dataface/core/compile/models/chart/normalized/_base.py +26 -0
  44. dataface/core/compile/models/chart/normalized/kpi.py +0 -4
  45. dataface/core/compile/models/chart/resolved/_base.py +28 -10
  46. dataface/core/compile/models/chart/resolved/callout.py +2 -3
  47. dataface/core/compile/models/chart/resolved/kpi.py +7 -3
  48. dataface/core/compile/models/chart/resolved/table.py +16 -2
  49. dataface/core/compile/models/config.py +5 -0
  50. dataface/core/compile/models/face/authored.py +24 -89
  51. dataface/core/compile/models/markers.py +1 -1
  52. dataface/core/compile/models/primitives.py +175 -2
  53. dataface/core/compile/models/query/authored.py +1 -119
  54. dataface/core/compile/models/refs.py +0 -2
  55. dataface/core/compile/models/source.py +78 -164
  56. dataface/core/compile/models/style/authored/__init__.py +32 -8
  57. dataface/core/compile/models/style/authored/_base.py +126 -69
  58. dataface/core/compile/models/style/authored/table.py +61 -3
  59. dataface/core/compile/models/style/resolved/__init__.py +12 -2
  60. dataface/core/compile/models/style/resolved/_base.py +142 -57
  61. dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
  62. dataface/core/compile/models/style/resolved/_cascade.py +141 -54
  63. dataface/core/compile/models/style/resolved/area.py +1 -1
  64. dataface/core/compile/models/style/resolved/bar.py +1 -1
  65. dataface/core/compile/models/style/resolved/line.py +1 -1
  66. dataface/core/compile/models/style/resolved/scatter.py +3 -2
  67. dataface/core/compile/models/style/theme/__init__.py +36 -14
  68. dataface/core/compile/models/style/theme/_chart_base.py +17 -9
  69. dataface/core/compile/models/style/theme/axis.py +432 -312
  70. dataface/core/compile/models/style/theme/charts.py +10 -6
  71. dataface/core/compile/models/style/theme/kpi.py +2 -1
  72. dataface/core/compile/models/style/theme/page.py +5 -2
  73. dataface/core/compile/models/style/theme/table.py +2 -2
  74. dataface/core/compile/models/style/theme/variables.py +0 -3
  75. dataface/core/compile/models/vega_lite/config.py +1 -1
  76. dataface/core/compile/normalize_charts.py +10 -154
  77. dataface/core/compile/normalize_layout.py +20 -284
  78. dataface/core/compile/normalize_queries.py +24 -513
  79. dataface/core/compile/normalizer.py +4 -10
  80. dataface/core/compile/palette.py +19 -7
  81. dataface/core/compile/resolve/__init__.py +2 -0
  82. dataface/core/compile/resolve/_axes.py +147 -44
  83. dataface/core/compile/resolve/_channels.py +1 -1
  84. dataface/core/compile/resolve/_dispatch.py +1 -1
  85. dataface/core/compile/resolve/_domain.py +49 -32
  86. dataface/core/compile/resolve/_kwargs.py +39 -12
  87. dataface/core/compile/resolve/_marks.py +8 -3
  88. dataface/core/compile/resolve/_palette.py +7 -3
  89. dataface/core/compile/resolve/_table.py +124 -33
  90. dataface/core/compile/resolve/area.py +48 -18
  91. dataface/core/compile/resolve/bar.py +245 -64
  92. dataface/core/compile/resolve/geo.py +10 -3
  93. dataface/core/compile/resolve/heatmap.py +16 -8
  94. dataface/core/compile/resolve/line.py +35 -12
  95. dataface/core/compile/resolve/pie.py +4 -1
  96. dataface/core/compile/resolve/pie_attachment.py +1 -1
  97. dataface/core/compile/resolve/scatter.py +25 -13
  98. dataface/core/compile/resolve/simple.py +25 -2
  99. dataface/core/compile/schema_renderers/prompt.py +1 -1
  100. dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
  101. dataface/core/compile/source_map.py +211 -28
  102. dataface/core/compile/style_cascade.py +126 -55
  103. dataface/core/compile/validator.py +121 -44
  104. dataface/core/compile/yaml_error_formatter.py +108 -3
  105. dataface/core/connections.py +10 -3
  106. dataface/core/dashboard.py +27 -19
  107. dataface/core/defaults/default_config.yml +4 -0
  108. dataface/core/defaults/themes/_base.yaml +49 -40
  109. dataface/core/defaults/themes/cream.yaml +4 -4
  110. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
  111. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
  112. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
  113. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
  114. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
  115. dataface/core/defaults/themes/editorial.yaml +9 -6
  116. dataface/core/defaults/themes/neon.yaml +15 -8
  117. dataface/core/defaults/themes/plain.yaml +6 -4
  118. dataface/core/defaults/themes/stark.yaml +24 -7
  119. dataface/core/defaults/themes/vivid.yaml +15 -8
  120. dataface/core/diagnostics/__init__.py +12 -2
  121. dataface/core/diagnostics/codes_compile.py +17 -33
  122. dataface/core/diagnostics/codes_execute.py +149 -0
  123. dataface/core/diagnostics/codes_render.py +144 -31
  124. dataface/core/diagnostics/diagnostic.py +30 -0
  125. dataface/core/diagnostics/hints.py +19 -0
  126. dataface/core/dialects/base.py +17 -0
  127. dataface/core/dialects/postgres.py +14 -0
  128. dataface/core/dialects/redshift.py +5 -0
  129. dataface/core/execute/__init__.py +59 -14
  130. dataface/core/execute/adapters/__init__.py +1 -1
  131. dataface/core/execute/adapters/dbt_adapter.py +4 -1
  132. dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
  133. dataface/core/execute/adapters/dbt_utils.py +84 -20
  134. dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
  135. dataface/core/execute/adapters/sql_adapter.py +72 -4
  136. dataface/core/execute/cache_keys.py +4 -9
  137. dataface/core/execute/dbt_jinja.py +65 -10
  138. dataface/core/execute/executor.py +94 -19
  139. dataface/core/execute/file_source_materializer.py +185 -32
  140. dataface/core/execute/trivial_local_cache.py +5 -2
  141. dataface/core/font_measure.py +28 -0
  142. dataface/core/fonts.py +8 -5
  143. dataface/core/inspect/__init__.py +46 -7
  144. dataface/core/project.py +19 -3
  145. dataface/core/render/chart/data_table_attachment.py +14 -12
  146. dataface/core/render/chart/emitters/_cartesian.py +21 -18
  147. dataface/core/render/chart/emitters/_channels.py +56 -8
  148. dataface/core/render/chart/emitters/_label_overlap.py +18 -15
  149. dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
  150. dataface/core/render/chart/emitters/area.py +3 -3
  151. dataface/core/render/chart/emitters/bar.py +60 -35
  152. dataface/core/render/chart/emitters/geo.py +8 -13
  153. dataface/core/render/chart/emitters/heatmap.py +20 -26
  154. dataface/core/render/chart/emitters/line.py +2 -2
  155. dataface/core/render/chart/emitters/scatter.py +7 -30
  156. dataface/core/render/chart/features/baseline.py +22 -9
  157. dataface/core/render/chart/features/endpoint_labels.py +55 -32
  158. dataface/core/render/chart/features/mirror_axis.py +12 -9
  159. dataface/core/render/chart/features/value_labels.py +1 -1
  160. dataface/core/render/chart/kpi.py +11 -90
  161. dataface/core/render/chart/mark_extents.py +339 -0
  162. dataface/core/render/chart/presentation.py +6 -8
  163. dataface/core/render/chart/rendering.py +100 -46
  164. dataface/core/render/chart/session.py +10 -0
  165. dataface/core/render/chart/spec.py +3 -0
  166. dataface/core/render/chart/spec_builders.py +18 -18
  167. dataface/core/render/chart/table.py +81 -36
  168. dataface/core/render/chart/table_support.py +2 -53
  169. dataface/core/render/chart/time_unit_detect.py +3 -2
  170. dataface/core/render/chart/translate.py +82 -44
  171. dataface/core/render/chart/type_inference.py +45 -25
  172. dataface/core/render/chart/validation.py +91 -0
  173. dataface/core/render/chart/vega_lite.py +30 -11
  174. dataface/core/render/chart/vl_field_maps.py +111 -76
  175. dataface/core/render/conditions.py +122 -0
  176. dataface/core/render/controls.py +60 -187
  177. dataface/core/render/converters/chart.py +16 -6
  178. dataface/core/render/data_format.py +1 -1
  179. dataface/core/render/face_resolve.py +7 -0
  180. dataface/core/render/faces.py +97 -32
  181. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
  182. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
  183. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
  184. dataface/core/render/fonts/_font_face.css +10 -0
  185. dataface/core/render/layout_sizing.py +313 -238
  186. dataface/core/render/numeral_expr.py +80 -0
  187. dataface/core/render/renderer.py +14 -1
  188. dataface/core/render/sizing.py +42 -105
  189. dataface/core/render/svg_utils.py +60 -14
  190. dataface/core/render/terminal.py +1 -1
  191. dataface/core/render/variables_layout.py +211 -0
  192. dataface/core/render/variables_resolve.py +376 -0
  193. dataface/core/render/variables_strip.py +291 -368
  194. dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
  195. dataface/core/render/warnings/base.py +18 -0
  196. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
  197. dataface/core/render/warnings/layout_min_exceeds_height.py +68 -0
  198. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
  199. dataface/core/render/warnings/palette_unsupported.py +6 -0
  200. dataface/core/render/warnings/pie_dominant_segment.py +1 -0
  201. dataface/core/render/warnings/pie_too_many_segments.py +1 -0
  202. dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
  203. dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
  204. dataface/core/render/warnings/redundant_encoding.py +20 -1
  205. dataface/core/render/warnings/registry.py +9 -3
  206. dataface/core/render/warnings/temporal_single_point.py +1 -0
  207. dataface/core/render/warnings/too_many_color_categories.py +1 -0
  208. dataface/core/render/warnings/too_many_x_categories.py +18 -2
  209. dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
  210. dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
  211. dataface/core/render_format.py +18 -0
  212. dataface/core/serve/alias_index.py +16 -22
  213. dataface/core/text/format_d3.py +56 -0
  214. dataface/core/text/numeral_scale.py +218 -0
  215. dataface/core/utils.py +103 -10
  216. dataface/data/schemas/board-resolved/board-resolved.schema.json +8589 -7578
  217. dataface/data/schemas/yaml/0.3.2.json +1 -0
  218. dataface/data/schemas/yaml/manifest.json +7 -0
  219. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev150.dist-info}/METADATA +1 -3
  220. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev150.dist-info}/RECORD +223 -213
  221. dataface/core/inspect/partials/categorical.yml +0 -38
  222. dataface/core/inspect/partials/date.yml +0 -38
  223. dataface/core/inspect/partials/numeric.yml +0 -53
  224. dataface/core/inspect/templates/charts.yml +0 -94
  225. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev150.dist-info}/WHEEL +0 -0
  226. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev150.dist-info}/entry_points.txt +0 -0
  227. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev150.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,10 @@ core/diagnostics/codes_render.py for what this fires on.
4
4
  Detection rule:
5
5
  chart is a (non-horizontal) ResolvedBarChart AND
6
6
  vega_specs[chart_id].encoding.x.type in {"nominal", "ordinal"} AND
7
- vega_specs[chart_id].width / (distinct x values in chart_results) < _MIN_BAND_WIDTH_PX
7
+ the estimated per-bar pixel width < _MIN_BAND_WIDTH_PX, where per-bar width
8
+ is vega_specs[chart_id].width / (distinct x values), further divided by the
9
+ series count when the emitted spec subdivides the band with an xOffset
10
+ channel — see _grouped_series_count below.
8
11
 
9
12
  Floor rationale: the theme's default bar border stroke is 1px
10
13
  (``marks.bar.border.width``) and the fill occupies ``band_width * 0.8``
@@ -19,6 +22,7 @@ from __future__ import annotations
19
22
 
20
23
  from dataface.core.compile.models.chart.resolved.bar import ResolvedBarChart
21
24
  from dataface.core.diagnostics import WARN_BAR_BAND_WIDTH_TOO_NARROW, Diagnostic
25
+ from dataface.core.render.chart._types import VLDict
22
26
  from dataface.core.render.warnings.base import WarningContext, encoding_channel_type
23
27
 
24
28
  # Below this per-band pixel width, the bar's fill is unreadable (see rationale above).
@@ -27,6 +31,57 @@ _MIN_BAND_WIDTH_PX = 4.0
27
31
  _CATEGORICAL_TYPES = frozenset({"nominal", "ordinal"})
28
32
 
29
33
 
34
+ def _grouped_series_count(
35
+ unit: VLDict, chart: ResolvedBarChart, rows: list[VLDict]
36
+ ) -> int:
37
+ """Bars-per-band from the emitter's own offset decision, or 1 if none.
38
+
39
+ The bar emitter (``render/chart/emitters/bar.py``) is the single source
40
+ of truth for whether a band is subdivided — it already accounts for
41
+ stack mode, ``overlap: full``, wide-form grouping (``y`` as a measure
42
+ list), and suppressing the offset channel when color is 1:1 with x (that
43
+ would draw one solo sub-band per category, not a genuine group). Reading
44
+ its emitted ``xOffset`` channel here means this detector reports exactly
45
+ what renders; re-deriving the same "is this grouped" predicate from
46
+ resolved fields drifted from that decision in both directions (false
47
+ positives on colors the emitter doesn't group by, false negatives on
48
+ wide-form measures it does).
49
+
50
+ Small multiples do NOT get a lower count: Vega-Lite resolves the offset
51
+ scale shared across facet panels by default (only ``y`` ever gets
52
+ ``resolve.scale: independent``), so a panel facing one series value still
53
+ divides its band across the *global* series domain, occupying a single
54
+ sub-slot — the real render measures sub-pixel bars here, not full-width
55
+ ones. Counting the offset field's cardinality in the chart's unpartitioned
56
+ rows is the correct domain, not an overcount.
57
+ """
58
+ encoding = unit.get("encoding")
59
+ offset = encoding.get("xOffset") if isinstance(encoding, dict) else None
60
+ if not isinstance(offset, dict):
61
+ return 1
62
+ # A continuous (gradient) offset field isn't a discrete grouping — guard
63
+ # the same way the x-axis categorical check above does.
64
+ if offset.get("type") not in _CATEGORICAL_TYPES:
65
+ return 1
66
+ offset_field = offset.get("field")
67
+ if not isinstance(offset_field, str):
68
+ return 1
69
+ # Wide-form (y as a list of measures) folds into a synthetic label field
70
+ # that Vega-Lite's own `fold` transform creates client-side — it is never
71
+ # present in the query rows, so the series count is just len(chart.y).
72
+ if isinstance(chart.y, list):
73
+ measures = len(chart.y)
74
+ else:
75
+ measures = len({row[offset_field] for row in rows if offset_field in row})
76
+ # A zero count means no series reached this detector at all — an empty
77
+ # result set, or an offset field absent from every row. Treat the band as
78
+ # undivided rather than dividing by zero: this is a diagnostic, and it must
79
+ # never be the thing that fails a render.
80
+ if measures == 0:
81
+ return 1
82
+ return measures
83
+
84
+
30
85
  def detect(ctx: WarningContext) -> list[Diagnostic]:
31
86
  """Return one Diagnostic per bar chart whose bands are sub-readable-width."""
32
87
  warnings: list[Diagnostic] = []
@@ -56,26 +111,29 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
56
111
  continue
57
112
 
58
113
  x_field: str = chart.x
59
- distinct = len(
60
- {row[x_field] for row in ctx.chart_results[chart_id] if x_field in row}
61
- )
114
+ rows = ctx.chart_results[chart_id]
115
+ distinct = len({row[x_field] for row in rows if x_field in row})
62
116
  if distinct == 0:
63
117
  continue
64
118
 
65
119
  band_width = render_width / distinct
66
- if band_width >= _MIN_BAND_WIDTH_PX:
120
+ series = _grouped_series_count(unit, chart, rows)
121
+ bar_width = band_width / series
122
+ if bar_width >= _MIN_BAND_WIDTH_PX:
67
123
  continue
68
124
 
69
125
  warnings.append(
70
126
  Diagnostic.from_code(
71
127
  WARN_BAR_BAND_WIDTH_TOO_NARROW,
72
128
  chart=chart_id,
129
+ path=f"charts.{chart_id}.x",
73
130
  field=x_field,
74
131
  message=WARN_BAR_BAND_WIDTH_TOO_NARROW.message_template.format(
75
132
  chart_id=chart_id,
76
133
  distinct=distinct,
134
+ series=series,
77
135
  render_width=render_width,
78
- band_width=band_width,
136
+ bar_width=bar_width,
79
137
  min_band_width=_MIN_BAND_WIDTH_PX,
80
138
  ),
81
139
  fix=WARN_BAR_BAND_WIDTH_TOO_NARROW.fix_template,
@@ -9,6 +9,8 @@ WarningContext carries everything detectors need:
9
9
  - chart_results: chart id → list of row dicts (query output).
10
10
  - vega_specs: chart id → Vega-Lite spec dict.
11
11
  - table_overflows: chart id → the table's slot overflow captured at render.
12
+ - authored_chart_heights: chart id → the nearest ancestor's literal
13
+ authored layout height, in px.
12
14
 
13
15
  vega_specs is SPARSE: KPI, text, and markdown charts do not compile to
14
16
  Vega-Lite, so their ids are OMITTED from this dict (not present as None).
@@ -19,6 +21,18 @@ Detectors that key into vega_specs must guard with:
19
21
  table_overflows is likewise SPARSE: only tables that overflowed their slot
20
22
  appear, and only on SVG-family renders (non-SVG formats never rasterize a
21
23
  table, so it is empty for them).
24
+
25
+ authored_chart_heights is likewise SPARSE: a chart with no authored height
26
+ anywhere in its ancestor chain is OMITTED, not present as 0 or None. The
27
+ value is a snapshot of the chart's real, assigned px slot height, taken by
28
+ build_resolved_face (via layout_sizing._snapshot_authored_slot_heights)
29
+ immediately after the sizing pass assigns it but before cols-alignment can
30
+ re-expand it — for a `cols:`-wrapped chart, that alignment step re-expands
31
+ the resolved height to the chart's natural/unconstrained size afterward to
32
+ keep siblings visually aligned, discarding the authored cap. Because the
33
+ value is read off the sizing pass's own output (not re-derived), it already
34
+ reflects a `rows:` wrapper splitting its height across children and a
35
+ percentage-authored height resolved against real available space.
22
36
  """
23
37
 
24
38
  from __future__ import annotations
@@ -42,6 +56,10 @@ class WarningContext(BaseModel):
42
56
  # chart id → the table's slot overflow at render time (tables only; sparse —
43
57
  # captured from the real SVG render, so empty for non-SVG formats).
44
58
  table_overflows: dict[str, TableOverflow] = {}
59
+ # chart id → nearest ancestor's literal authored layout height, in px
60
+ # (sparse — see module docstring for why this isn't derived from
61
+ # face_spec.layout's resolved .height).
62
+ authored_chart_heights: dict[str, float] = {}
45
63
 
46
64
 
47
65
  def encoding_channel_type(vega_spec: dict[str, object], channel: str) -> str:
@@ -13,7 +13,9 @@ Detection rule:
13
13
  having axis_y means the user has already opted into split scales)
14
14
 
15
15
  Diagnostic.field is None — the issue is cross-column, not column-scoped.
16
- The message names the two columns with the widest ratio.
16
+ The message names the two columns with the widest ratio, and the diagnostic
17
+ marks where each was authored: `path` on the larger series, `related` on the
18
+ smaller one that gets crushed.
17
19
  """
18
20
 
19
21
  from __future__ import annotations
@@ -24,6 +26,7 @@ from dataface.core.compile.models.chart.resolved._layer import LayeredResolvedCh
24
26
  from dataface.core.diagnostics import (
25
27
  WARN_LAYERED_CHART_SHARED_Y_AXIS_SCALE_MISMATCH,
26
28
  Diagnostic,
29
+ RelatedLocation,
27
30
  )
28
31
  from dataface.core.render.warnings.base import WarningContext
29
32
 
@@ -54,14 +57,19 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
54
57
  continue
55
58
 
56
59
  # Collect (column_name, absolute_median, axis_y_set) for the base
57
- # chart's own y column plus every layer's y column.
60
+ # chart's own y column plus every layer's y column. The authored path
61
+ # each column came from rides along so the two extremes can be marked
62
+ # where they were actually written.
58
63
  column_axis_y: list[tuple[str, bool]] = []
64
+ column_paths: dict[str, str] = {}
59
65
  base_y = chart.y
60
66
  if isinstance(base_y, str):
61
67
  column_axis_y.append((base_y, False))
62
- for layer in chart.layers:
68
+ column_paths[base_y] = f"charts.{chart_id}.y"
69
+ for idx, layer in enumerate(chart.layers):
63
70
  if layer.y is not None:
64
71
  column_axis_y.append((layer.y, layer.axis_y.position is not None))
72
+ column_paths.setdefault(layer.y, f"charts.{chart_id}.layers.{idx}.y")
65
73
 
66
74
  layer_stats: list[tuple[str, float, bool]] = []
67
75
  seen_columns: set[str] = set()
@@ -101,11 +109,25 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
101
109
  if max_axis_y or min_axis_y:
102
110
  continue
103
111
 
112
+ # The complaint is the *ratio between two series*, so it is anchored on
113
+ # the larger one and carries the smaller — the one being crushed to a
114
+ # flat line — as a related location. Marking only one of the pair, or
115
+ # the whole chart block, loses which two columns are at odds.
104
116
  warnings.append(
105
117
  Diagnostic.from_code(
106
118
  WARN_LAYERED_CHART_SHARED_Y_AXIS_SCALE_MISMATCH,
107
119
  chart=chart_id,
108
120
  field=None,
121
+ path=column_paths[max_col],
122
+ related=(
123
+ RelatedLocation(
124
+ path=column_paths[min_col],
125
+ message=(
126
+ f"{min_col!r} is ~{ratio:.0f}x smaller — this is the "
127
+ "series that flattens"
128
+ ),
129
+ ),
130
+ ),
109
131
  message=WARN_LAYERED_CHART_SHARED_Y_AXIS_SCALE_MISMATCH.message_template.format(
110
132
  chart_id=chart_id, col_a=max_col, col_b=min_col, ratio=ratio
111
133
  ),
@@ -0,0 +1,68 @@
1
+ """Detector: WARN_LAYOUT_MIN_EXCEEDS_HEIGHT — see its `doc` in
2
+ core/diagnostics/codes_render.py for what this fires on.
3
+
4
+ Detection rule:
5
+ chart is a horizontal ResolvedBarChart AND
6
+ it has a literal authored height somewhere in its ancestor chain
7
+ (ctx.authored_chart_heights) AND
8
+ min_height_for_horizontal_bar_categories(n, ...) — the same readability
9
+ floor vega_lite.py's _render_vl_artifact applies at render time — exceeds
10
+ that authored height.
11
+
12
+ The authored height comes from ctx.authored_chart_heights, a snapshot of
13
+ each chart's real, assigned px slot height taken while it's still intact —
14
+ after the sizing pass assigns it, before cols-alignment can re-expand it.
15
+ See layout_sizing.py's `_snapshot_authored_slot_heights` for the full
16
+ mechanism.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataface.core.compile.models.chart.resolved.bar import ResolvedBarChart
22
+ from dataface.core.diagnostics import WARN_LAYOUT_MIN_EXCEEDS_HEIGHT, Diagnostic
23
+ from dataface.core.render.chart.emitters._cartesian import (
24
+ count_horizontal_bar_categories,
25
+ min_height_for_horizontal_bar_categories,
26
+ )
27
+ from dataface.core.render.warnings.base import WarningContext
28
+
29
+
30
+ def detect(ctx: WarningContext) -> list[Diagnostic]:
31
+ """Return one Diagnostic per horizontal bar whose category floor exceeds
32
+ its nearest ancestor's authored height."""
33
+ warnings: list[Diagnostic] = []
34
+
35
+ for chart_id, chart in ctx.face_spec.charts.items():
36
+ if not isinstance(chart, ResolvedBarChart):
37
+ continue
38
+ if chart.orientation != "horizontal":
39
+ continue
40
+
41
+ authored_height = ctx.authored_chart_heights.get(chart_id)
42
+ if authored_height is None:
43
+ continue
44
+
45
+ rows = ctx.chart_results.get(chart_id)
46
+ if not rows:
47
+ continue
48
+
49
+ n = count_horizontal_bar_categories(chart.x, rows)
50
+ min_h = min_height_for_horizontal_bar_categories(n, ctx.face_spec.style.charts)
51
+ if min_h <= authored_height:
52
+ continue
53
+
54
+ warnings.append(
55
+ Diagnostic.from_code(
56
+ WARN_LAYOUT_MIN_EXCEEDS_HEIGHT,
57
+ chart=chart_id,
58
+ message=WARN_LAYOUT_MIN_EXCEEDS_HEIGHT.message_template.format(
59
+ chart_id=chart_id,
60
+ n_categories=n,
61
+ min_height=min_h,
62
+ authored_height=authored_height,
63
+ ),
64
+ fix=WARN_LAYOUT_MIN_EXCEEDS_HEIGHT.fix_template,
65
+ )
66
+ )
67
+
68
+ return warnings
@@ -13,13 +13,13 @@ Detection rule (v1, name-match only):
13
13
  or contains the substring: percent.
14
14
 
15
15
  Fire when a matching field's effective y-axis format does not suit its kind.
16
- Every format source (chart-root ``format:``, authored ``style.axis_y.format``,
16
+ Every format source (chart-root ``format:``, authored ``style.axis_y.labels.format``,
17
17
  whole-chart ``style.number_format``, the theme default) is baked into the single
18
- resolved ``style.axis_y.format`` by the time this detector runs, so it reads
18
+ resolved ``style.axis_y.labels.format`` by the time this detector runs, so it reads
19
19
  that one value rather than trying to recover authored-vs-default intent. A
20
20
  percent format carries ``%``; a currency format carries ``$``; the generic SI
21
21
  default (``.3~s``) suits neither.
22
- - Cartesian charts: check the chart's ``style.axis_y.format``.
22
+ - Cartesian charts: check the chart's ``style.axis_y.labels.format``.
23
23
  - Layered charts share one y-axis, so its format is checked once per layer;
24
24
  a warning is emitted per layer whose y field matches a signal and whose
25
25
  shared format is unfit.
@@ -108,9 +108,9 @@ def _classify(field_name: str) -> str | None:
108
108
  def _format_suits_kind(fmt: str | None, kind: str) -> bool:
109
109
  """Whether a resolved d3 number-format string renders ``kind`` sanely.
110
110
 
111
- Every format source (chart-root ``format:``, authored ``style.axis_y.format``,
111
+ Every format source (chart-root ``format:``, authored ``style.axis_y.labels.format``,
112
112
  the theme default, and whole-chart ``style.number_format``) is baked into the
113
- single ``style.axis_y.format`` by the resolved boundary, so authored-vs-default
113
+ single ``style.axis_y.labels.format`` by the resolved boundary, so authored-vs-default
114
114
  can't be read off the resolved chart — but the effective format can. The
115
115
  generic SI default (``.3~s``) suits neither kind: a 0–1 percent renders as
116
116
  milli-units and currency loses its symbol. A percent format carries ``%``; a
@@ -125,6 +125,12 @@ def _warn(chart_id: str, field: str, kind: str, axis_format: str | None) -> Diag
125
125
  return Diagnostic.from_code(
126
126
  WARN_LIKELY_CURRENCY_OR_PERCENT_MISSING_FORMATTER,
127
127
  chart=chart_id,
128
+ # `format` lives on `axis_y.labels`, not on `axis_y` — the bare
129
+ # `axis_y.format` spelling is pre-0.3.2 and now emits a migration
130
+ # warning. This is the spelling `fix_template` tells authors to set.
131
+ # Usually an *absent* key (that is the complaint), so it leans on the
132
+ # candidate walk to land on `axis_y`, then `style`, then the chart.
133
+ path=f"charts.{chart_id}.style.axis_y.labels.format",
128
134
  field=field,
129
135
  message=WARN_LIKELY_CURRENCY_OR_PERCENT_MISSING_FORMATTER.message_template.format(
130
136
  chart_id=chart_id, field=field, kind=kind, format=axis_format
@@ -154,7 +160,7 @@ def _y_axis_check(chart: ResolvedChart) -> tuple[str | None, list[str]]:
154
160
  fields.extend(layer.y for layer in chart.layers if layer.y)
155
161
  if not fields:
156
162
  return None, []
157
- return chart.style.axis_y.format, fields
163
+ return chart.style.axis_y.labels.format, fields
158
164
 
159
165
 
160
166
  def detect(ctx: WarningContext) -> list[Diagnostic]:
@@ -30,6 +30,12 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
30
30
  Diagnostic.from_code(
31
31
  WARN_PALETTE_UNSUPPORTED,
32
32
  chart=chart_id,
33
+ # `requested_alias_palette` is set on CategoricalColorStyle, so
34
+ # the authored key is under `style.color.categorical` — either
35
+ # `palette` or `single_series_palette`. Naming the common one
36
+ # walks up one level for the other, which is still the right
37
+ # block. There is no `scale` node on ColorStyle.
38
+ path=f"charts.{chart_id}.style.color.categorical.palette",
33
39
  field="palette",
34
40
  message=WARN_PALETTE_UNSUPPORTED.message_template.format(
35
41
  chart_id=chart_id,
@@ -50,6 +50,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
50
50
  Diagnostic.from_code(
51
51
  WARN_PIE_DOMINANT_SEGMENT,
52
52
  chart=chart_id,
53
+ path=f"charts.{chart_id}.type",
53
54
  field=theta_field,
54
55
  message=WARN_PIE_DOMINANT_SEGMENT.message_template.format(
55
56
  chart_id=chart_id, dominant_field=theta_field, dominant_share=share
@@ -33,6 +33,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
33
33
  Diagnostic.from_code(
34
34
  WARN_PIE_TOO_MANY_SEGMENTS,
35
35
  chart=chart_id,
36
+ path=f"charts.{chart_id}.type",
36
37
  message=WARN_PIE_TOO_MANY_SEGMENTS.message_template.format(
37
38
  chart_id=chart_id,
38
39
  segment_count=segment_count,
@@ -63,6 +63,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
63
63
  Diagnostic.from_code(
64
64
  WARN_POINT_MAP_OUT_OF_PROJECTION,
65
65
  chart=chart_id,
66
+ path=f"charts.{chart_id}.projection",
66
67
  message=WARN_POINT_MAP_OUT_OF_PROJECTION.message_template.format(
67
68
  chart_id=chart_id,
68
69
  dropped_count=out_count,
@@ -29,6 +29,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
29
29
  Diagnostic.from_code(
30
30
  WARN_QUERY_RETURNED_ZERO_ROWS,
31
31
  chart=chart_id,
32
+ path=f"charts.{chart_id}.query",
32
33
  field=None,
33
34
  message=WARN_QUERY_RETURNED_ZERO_ROWS.message_template.format(
34
35
  chart_id=chart_id
@@ -20,7 +20,11 @@ from dataface.core.compile.models.chart.resolved._base import (
20
20
  )
21
21
  from dataface.core.compile.models.chart.resolved.pie import ResolvedPieChart
22
22
  from dataface.core.compile.models.chart.resolved.scatter import ResolvedScatterChart
23
- from dataface.core.diagnostics import WARN_REDUNDANT_ENCODING, Diagnostic
23
+ from dataface.core.diagnostics import (
24
+ WARN_REDUNDANT_ENCODING,
25
+ Diagnostic,
26
+ RelatedLocation,
27
+ )
24
28
  from dataface.core.render.warnings.base import WarningContext
25
29
 
26
30
 
@@ -62,11 +66,26 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
62
66
  if chart.chart_type == "bar" and set(channels) == {"x", "color"}:
63
67
  continue
64
68
  channel_list = ", ".join(sorted(channels))
69
+ # The complaint is about the *pair*, so marking one channel alone
70
+ # tells half of it. Anchor the first and carry every other one as a
71
+ # related location, each labelled with the channel it is.
72
+ first, *rest = sorted(channels)
65
73
  warnings.append(
66
74
  Diagnostic.from_code(
67
75
  WARN_REDUNDANT_ENCODING,
68
76
  chart=chart_id,
69
77
  field=field,
78
+ path=f"charts.{chart_id}.{first}",
79
+ related=tuple(
80
+ RelatedLocation(
81
+ path=f"charts.{chart_id}.{channel}",
82
+ message=(
83
+ f"also bound to {field!r} — this is the "
84
+ "redundant binding"
85
+ ),
86
+ )
87
+ for channel in rest
88
+ ),
70
89
  message=WARN_REDUNDANT_ENCODING.message_template.format(
71
90
  chart_id=chart_id, field=field, channels=channel_list
72
91
  ),
@@ -15,6 +15,7 @@ from types import ModuleType
15
15
 
16
16
  import dataface.core.render.warnings.bar_band_width_too_narrow as bar_band_width_too_narrow
17
17
  import dataface.core.render.warnings.layered_chart_shared_y_axis_scale_mismatch as layered_chart_shared_y_axis_scale_mismatch
18
+ import dataface.core.render.warnings.layout_min_exceeds_height as layout_min_exceeds_height
18
19
  import dataface.core.render.warnings.likely_currency_or_percent_missing_formatter as likely_currency_or_percent_missing_formatter
19
20
  import dataface.core.render.warnings.palette_unsupported as palette_unsupported
20
21
  import dataface.core.render.warnings.pie_dominant_segment as pie_dominant_segment
@@ -36,6 +37,7 @@ logger = logging.getLogger(__name__)
36
37
  DETECTORS: list[ModuleType] = [
37
38
  bar_band_width_too_narrow,
38
39
  layered_chart_shared_y_axis_scale_mismatch,
40
+ layout_min_exceeds_height,
39
41
  likely_currency_or_percent_missing_formatter,
40
42
  palette_unsupported,
41
43
  pie_dominant_segment,
@@ -64,9 +66,13 @@ def run_all(ctx: WarningContext) -> list[Diagnostic]:
64
66
  results.extend(detector.detect(ctx))
65
67
  except Exception: # noqa: BLE001 — detector failures must not break renders
66
68
  logger.exception("Warning detector %r raised; skipping", detector)
67
- # One central point derives .path from .chart for every detector's output —
68
- # a new detector needs zero position wiring of its own to become
69
- # source-map-stampable; it only has to stamp .chart, which it already does.
69
+ # A detector that knows which authored key its complaint is about composes
70
+ # its own `charts.<id>.<anchor>` path, so the squiggle lands on that field
71
+ # rather than the whole chart block. The fallback below is for the
72
+ # detectors that genuinely have nothing more specific to say — an
73
+ # unreferenced chart, a table too wide for its slot — and it keeps a new
74
+ # detector stampable with zero position wiring: stamping `.chart`, which it
75
+ # already does, is enough.
70
76
  for d in results:
71
77
  if d.path is None and d.chart is not None:
72
78
  d.path = f"charts.{d.chart}"
@@ -48,6 +48,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
48
48
  Diagnostic.from_code(
49
49
  WARN_TEMPORAL_SINGLE_POINT,
50
50
  chart=chart_id,
51
+ path=f"charts.{chart_id}.x",
51
52
  field=chart.x,
52
53
  message=WARN_TEMPORAL_SINGLE_POINT.message_template.format(
53
54
  chart_id=chart_id, chart_type=chart.chart_type
@@ -47,6 +47,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
47
47
  Diagnostic.from_code(
48
48
  WARN_TOO_MANY_COLOR_CATEGORIES,
49
49
  chart=chart_id,
50
+ path=f"charts.{chart_id}.color",
50
51
  field=color_field,
51
52
  message=WARN_TOO_MANY_COLOR_CATEGORIES.message_template.format(
52
53
  chart_id=chart_id,
@@ -2,7 +2,8 @@
2
2
  core/diagnostics/codes_render.py for what this fires on.
3
3
 
4
4
  Detection rule:
5
- vega_specs[chart_id].encoding.x.type in {"nominal", "ordinal"}
5
+ vega_specs[chart_id].encoding.x.type in {"nominal", "ordinal"} (or, for a
6
+ bar chart, also "temporal" — see _BAR_ALLOWED_X_TYPES below)
6
7
  AND distinct x values in chart_results > _MAX_CATEGORIES
7
8
  """
8
9
 
@@ -11,6 +12,7 @@ from __future__ import annotations
11
12
  from dataface.core.compile.models.chart.resolved._base import (
12
13
  _CartesianResolvedChartFields,
13
14
  )
15
+ from dataface.core.compile.models.chart.resolved.bar import ResolvedBarChart
14
16
  from dataface.core.diagnostics import WARN_TOO_MANY_X_CATEGORIES, Diagnostic
15
17
  from dataface.core.render.warnings.base import WarningContext, encoding_channel_type
16
18
 
@@ -19,6 +21,14 @@ _MAX_CATEGORIES = 50
19
21
 
20
22
  _CATEGORICAL_TYPES = frozenset({"nominal", "ordinal"})
21
23
 
24
+ # A bar draws one band per distinct x value regardless of whether Vega-Lite
25
+ # encodes that axis as ordinal or temporal — the density gate that flips
26
+ # bucketed temporal data off ordinal above ~60 distinct values does so purely
27
+ # to manage axis-label layout, not because the chart stopped being banded.
28
+ # Line/area/scatter charts don't get this widening: a dense temporal axis
29
+ # there is a continuous draw, not a crowded band, however many points it has.
30
+ _BAR_ALLOWED_X_TYPES = _CATEGORICAL_TYPES | frozenset({"temporal"})
31
+
22
32
 
23
33
  def detect(ctx: WarningContext) -> list[Diagnostic]:
24
34
  """Return one Diagnostic per chart with an overcrowded categorical x-axis."""
@@ -32,8 +42,13 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
32
42
  if chart_id not in ctx.vega_specs or chart_id not in ctx.chart_results:
33
43
  continue
34
44
 
45
+ allowed_x_types = (
46
+ _BAR_ALLOWED_X_TYPES
47
+ if isinstance(chart, ResolvedBarChart)
48
+ else _CATEGORICAL_TYPES
49
+ )
35
50
  x_type = encoding_channel_type(ctx.vega_specs[chart_id], "x")
36
- if x_type not in _CATEGORICAL_TYPES:
51
+ if x_type not in allowed_x_types:
37
52
  continue
38
53
 
39
54
  x_field: str = chart.x
@@ -47,6 +62,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
47
62
  Diagnostic.from_code(
48
63
  WARN_TOO_MANY_X_CATEGORIES,
49
64
  chart=chart_id,
65
+ path=f"charts.{chart_id}.x",
50
66
  field=x_field,
51
67
  message=WARN_TOO_MANY_X_CATEGORIES.message_template.format(
52
68
  chart_id=chart_id,
@@ -41,12 +41,18 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
41
41
  # length (a different axis) and are out of scope; area labels ride the
42
42
  # overlaid line mark.
43
43
  labels: MarkLabelsStyle
44
+ # `mark_key` is the authored `marks.<key>` the labels came from, so the
45
+ # warning marks where they were switched on. Area labels ride the
46
+ # overlaid *line* mark, which is why it is not always the family name.
47
+ mark_key: str
44
48
  if isinstance(chart, ResolvedBarChart):
45
49
  if chart.orientation == "horizontal":
46
50
  continue
47
51
  labels = chart.style.mark.labels
52
+ mark_key = "bar"
48
53
  elif isinstance(chart, (ResolvedLineChart, ResolvedAreaChart)):
49
54
  labels = chart.style.line_mark.labels
55
+ mark_key = "line"
50
56
  else:
51
57
  continue
52
58
 
@@ -109,6 +115,9 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
109
115
  Diagnostic.from_code(
110
116
  WARN_VALUE_LABELS_CROWD_WIDTH,
111
117
  chart=chart_id,
118
+ # Chart-local style takes no family segment — `style.bar.…` is
119
+ # the board-level spelling and does not compile on a chart.
120
+ path=f"charts.{chart_id}.style.marks.{mark_key}.labels",
112
121
  field=label_field,
113
122
  message=WARN_VALUE_LABELS_CROWD_WIDTH.message_template.format(
114
123
  chart_id=chart_id, label_width=widest, slot_width=slot
@@ -54,6 +54,7 @@ def detect(ctx: WarningContext) -> list[Diagnostic]:
54
54
  Diagnostic.from_code(
55
55
  WARN_Y_ENCODING_MOSTLY_NULL,
56
56
  chart=chart_id,
57
+ path=f"charts.{chart_id}.y",
57
58
  field=y_field,
58
59
  message=WARN_Y_ENCODING_MOSTLY_NULL.message_template.format(
59
60
  chart_id=chart_id,
@@ -0,0 +1,18 @@
1
+ """The `RenderFormat` type alias, split out of `dataface.core.dashboard`.
2
+
3
+ `RenderFormat` is pure type metadata with no runtime logic — but `dashboard.py`
4
+ (where it used to live) is the compile+execute+render orchestrator and eagerly
5
+ imports the entire `core.compile` package. CLI/MCP surfaces that need
6
+ `RenderFormat` purely for argument typing (e.g. Typer's `--format` option,
7
+ which needs the real `Literal` object at decoration time, not just an
8
+ annotation) were paying that whole cost just to get this one type. Living
9
+ here, they don't.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Literal
15
+
16
+ RenderFormat = Literal[
17
+ "svg", "html", "png", "pdf", "terminal", "json", "text", "yaml", "data"
18
+ ]