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
@@ -10,7 +10,10 @@ from dataface.core.compile.models.chart.resolved.bar import ResolvedBarChart
10
10
  from dataface.core.compile.models.primitives import OverlapSpec
11
11
  from dataface.core.compile.tick_values import numeric_domain_bounds
12
12
  from dataface.core.diagnostics.chart_data import ChartDataError
13
- from dataface.core.diagnostics.codes_render import ERR_HISTOGRAM_NON_NUMERIC
13
+ from dataface.core.diagnostics.codes_render import (
14
+ ERR_HISTOGRAM_NON_NUMERIC,
15
+ ERR_LABEL_VALUES_NOT_TEMPORAL,
16
+ )
14
17
  from dataface.core.render.chart._types import VLDict
15
18
  from dataface.core.render.chart.emitters._cartesian import (
16
19
  apply_domain_headroom_bounds,
@@ -51,14 +54,14 @@ from dataface.core.render.chart.type_inference import (
51
54
  from dataface.core.render.chart.validation import (
52
55
  validate_color_series,
53
56
  validate_preaggregated_data,
57
+ validate_raw_rows_for_histogram,
54
58
  )
55
59
  from dataface.core.render.chart.vl_field_maps import (
56
- SCALE_FIELD_MAP,
57
60
  axis_to_vl,
58
61
  bar_corner_props,
59
62
  bar_mark_radius,
60
63
  bar_mark_to_vl,
61
- map_fields,
64
+ emit_resolved_scale_vl,
62
65
  measure_axis_to_vl,
63
66
  )
64
67
  from dataface.core.render.utils import normalize_data_types
@@ -230,7 +233,9 @@ def _emit_histogram(
230
233
  """Emit a histogram spec: binned quantitative x, aggregate-count y, color-only grouping.
231
234
 
232
235
  Mirrors V1 _map_histogram exactly. Does NOT call validate_preaggregated_data —
233
- histograms consume raw rows (VL handles binning + aggregation).
236
+ histograms consume raw rows (VL handles binning + aggregation) — but does
237
+ call validate_raw_rows_for_histogram, the mirror-image guard that rejects
238
+ the opposite mistake: data that's already one row per bucket.
234
239
  """
235
240
  # Validate before building: missing or non-numeric x crashes vl_convert.
236
241
  if chart.x is None:
@@ -248,6 +253,7 @@ def _emit_histogram(
248
253
  field=chart.x,
249
254
  vl_type=x_vl_type,
250
255
  )
256
+ validate_raw_rows_for_histogram(chart, data)
251
257
 
252
258
  # Histogram bars are always positive (counts) → top-anchored corner rounding.
253
259
  # bar_mark_to_vl no longer carries the corner radius (sign-aware rounding
@@ -414,7 +420,7 @@ class BarEmitter:
414
420
  chart_id=chart.id,
415
421
  axis_y=chart.style.axis_y,
416
422
  tooltip_format=chart.style.tooltip_format,
417
- background=chart.style.background,
423
+ background=chart.background,
418
424
  single_series_fill=chart.style.single_series_fill,
419
425
  legend=chart.legend,
420
426
  config=config,
@@ -562,11 +568,7 @@ def _emit_vertical(
562
568
  x_scale: dict[str, Any] = {}
563
569
  if ax.scale is not None and ax.scale.padding is not None:
564
570
  x_scale["padding"] = ax.scale.padding
565
- # chart-local band_padding_inner wins over theme bar.padding shorthand
566
- if ax.scale is not None and ax.scale.band_padding_inner is not None:
567
- x_scale["paddingInner"] = ax.scale.band_padding_inner
568
- else:
569
- x_scale["paddingInner"] = bar_mark.padding
571
+ x_scale["paddingInner"] = bar_mark.padding
570
572
  x_scale.update(cartesian_x_scale_domain(ax.scale, x_vl_type, chart.id))
571
573
  if x_vl_type == "quantitative" and bar_mark.size is not None:
572
574
  # Vega-Lite centers each fixed continuousBandSize bar on its data
@@ -630,10 +632,11 @@ def _emit_vertical(
630
632
  ay_vl["values"] = list(_NORMALIZE_QUARTILE_TICKS)
631
633
  else:
632
634
  is_stacked = bool(measure_field) and chart.stack not in (None, "none")
633
- ay_scale_zero = ay.scale.zero if ay.scale is not None else None
635
+ _ay_cont = ay.scale.continuous if ay.scale is not None else None
636
+ ay_scale_zero = _ay_cont.zero if _ay_cont is not None else None
634
637
  bar_zero = not is_stacked and ay_scale_zero is not False
635
638
  authored_domain = numeric_domain_bounds(
636
- ay.scale.domain if ay.scale is not None else None
639
+ _ay_cont.domain if _ay_cont is not None else None
637
640
  )
638
641
  y_ticks: list[float] | None = list(ay.tick_values) if ay.tick_values else None
639
642
  if y_ticks and "values" not in ay_vl:
@@ -642,14 +645,14 @@ def _emit_vertical(
642
645
  if authored_domain is not None:
643
646
  y_scale = {"domain": list(authored_domain)}
644
647
  if ay.scale is not None:
645
- y_scale.update(map_fields(ay.scale, SCALE_FIELD_MAP))
648
+ y_scale.update(emit_resolved_scale_vl(ay.scale))
646
649
  # An authored domain already IS the exact bounds — "zero" is a
647
650
  # domain-inference hint, meaningless once the domain is pinned.
648
651
  y_scale.pop("zero", None)
649
652
  elif bar_zero:
650
653
  y_scale = {"domainMin": (y_ticks[0] if y_ticks else 0.0), "zero": True}
651
654
  if ay.scale is not None:
652
- extra_scale = map_fields(ay.scale, SCALE_FIELD_MAP)
655
+ extra_scale = emit_resolved_scale_vl(ay.scale)
653
656
  extra_scale.pop("zero", None)
654
657
  y_scale.update(extra_scale)
655
658
  else:
@@ -780,6 +783,29 @@ def _emit_horizontal(
780
783
  "chart must go through the resolve pipeline before emitting."
781
784
  )
782
785
 
786
+ # style.axis_x.labels.values only has meaning against a temporal/ISO-bucketed
787
+ # scale (the labelExpr membership filter build_cartesian_x_encoding wires
788
+ # in for the vertical path). Horizontal bar's categorical axis is always
789
+ # emitted as a flat VL "nominal" y-encoding (see y_enc below) — there is no
790
+ # date-aware label path here for the filter to hook into, so honoring it
791
+ # would be a no-op regardless of whether the values are ISO-shaped. Raise
792
+ # the same coded error the vertical path raises for non-temporal x, rather
793
+ # than silently dropping the authored filter. Unlike the vertical path's
794
+ # guard, this fires unconditionally on ISO-ness (an explicit
795
+ # orientation: horizontal override with genuinely ISO-shaped x data would
796
+ # still be unable to honor labels.values), so the cause/remedy passed here
797
+ # name the orientation, never claim the data itself is invalid.
798
+ if ax.labels.values is not None:
799
+ raise ChartDataError.from_code(
800
+ ERR_LABEL_VALUES_NOT_TEMPORAL,
801
+ field=cat_field,
802
+ cause=(
803
+ "a horizontal bar's categorical axis never honors it, "
804
+ "regardless of date format"
805
+ ),
806
+ remedy="Set orientation: vertical, or remove labels.values.",
807
+ )
808
+
783
809
  # VL x = measure axis.
784
810
  color_ch_early = chart.resolved_channels.get("color") if wide is False else None
785
811
  measure_field = (
@@ -809,16 +835,15 @@ def _emit_horizontal(
809
835
  # In Vega-Lite, paddingInner on a quantitative scale is a no-op visually, but
810
836
  # V1 emits it unconditionally via effective.scale applied to all channels,
811
837
  # and the existing tests assert it is present here.
812
- _ay_scale_zero = ay.scale.zero if ay.scale is not None else None
838
+ _ay_cont_h = ay.scale.continuous if ay.scale is not None else None
839
+ _ay_scale_zero_h = _ay_cont_h.zero if _ay_cont_h is not None else None
813
840
  x_enc_scale: VLDict = {
814
- "zero": _ay_scale_zero if isinstance(_ay_scale_zero, bool) else True
841
+ "zero": _ay_scale_zero_h if isinstance(_ay_scale_zero_h, bool) else True
815
842
  }
816
843
  if ay.scale is not None:
817
- extra_x_scale = map_fields(ay.scale, SCALE_FIELD_MAP)
844
+ extra_x_scale = emit_resolved_scale_vl(ay.scale)
818
845
  extra_x_scale.pop("zero", None)
819
846
  x_enc_scale.update(extra_x_scale)
820
- if ax.scale is not None and ax.scale.band_padding_inner is not None:
821
- x_enc_scale["paddingInner"] = ax.scale.band_padding_inner
822
847
  x_enc: dict[str, Any] = {
823
848
  "field": measure_field,
824
849
  "type": "quantitative",
@@ -835,8 +860,9 @@ def _emit_horizontal(
835
860
  ay_vl["values"] = list(_NORMALIZE_QUARTILE_TICKS)
836
861
  else:
837
862
  # domainMax: use baked stacked total; fall back to nice-tick top.
863
+ _ay_cont_x = ay.scale.continuous if ay.scale is not None else None
838
864
  authored_x_domain = numeric_domain_bounds(
839
- ay.scale.domain if ay.scale is not None else None
865
+ _ay_cont_x.domain if _ay_cont_x is not None else None
840
866
  )
841
867
  if measure_field and chart.stack not in (None, "none"):
842
868
  if authored_x_domain is None:
@@ -852,7 +878,10 @@ def _emit_horizontal(
852
878
  x_enc["scale"], ay.domain_max, ay.domain_min
853
879
  )
854
880
 
855
- # Categorical axis (VL y): axis_to_vl(ax) + orient from ay.categorical_orient
881
+ # Categorical axis (VL y): axis_to_vl(ax) + orient from ay.position
882
+ # (categorical_orient was deleted 2026-08 — position now serves this role
883
+ # too; _resolve.py bakes it "left"-default here via _bake_ay_position_left,
884
+ # matching the deleted field's static default, before computing x_edge).
856
885
  # Horizontal bar: categorical labels on VL y; is_horizontal_bar pins allow+angle=0.
857
886
  label_layout = resolve_axis_x_overlap(
858
887
  ax,
@@ -868,11 +897,11 @@ def _emit_horizontal(
868
897
  label_overlap=label_layout.label_overlap,
869
898
  label_angle=label_layout.angle,
870
899
  )
871
- if ay.categorical_orient is not None:
872
- ax_vl["orient"] = ay.categorical_orient
873
- # Own-side align (ax.label.align resolved to the axis's own
900
+ if ay.position is not None:
901
+ ax_vl["orient"] = ay.position
902
+ # Own-side align (ax.labels.align resolved to the axis's own
874
903
  # orient side — either authored directly, or resolve-mapped from
875
- # charts.bar.axis_x.label.align: inward) flips label growth back toward
904
+ # charts.bar.axis_x.labels.align: inward) flips label growth back toward
876
905
  # the plot. Symmetric case to vl_field_maps.py's measure_axis_to_vl guard.
877
906
  cat_orient = ax_vl.get("orient")
878
907
  # Every row, not a capped sample: an under-measured gutter from a wide
@@ -891,7 +920,7 @@ def _emit_horizontal(
891
920
  # measure (zero rows). No font.case exclusion here: horizontal bar's
892
921
  # categorical axis never runs inject_axis_label_case (that only wires
893
922
  # into build_cartesian_x_encoding's temporal/ordinal x path, which this
894
- # VL-y categorical axis doesn't go through), so label.font.case has no
923
+ # VL-y categorical axis doesn't go through), so labels.font.case has no
895
924
  # rendered effect to mismeasure against.
896
925
  if (
897
926
  cat_orient in ("left", "right")
@@ -900,12 +929,12 @@ def _emit_horizontal(
900
929
  and cat_labels
901
930
  ):
902
931
  cat_tick_size = (
903
- ax.ticks.size if ax.ticks.size is not None else DEFAULT_VL_TICK_SIZE
932
+ ax.ticks.length if ax.ticks.length is not None else DEFAULT_VL_TICK_SIZE
904
933
  )
905
- cat_padding = measured_label_padding(cat_labels, ax.label.font, cat_tick_size)
934
+ cat_padding = measured_label_padding(cat_labels, ax.labels.font, cat_tick_size)
906
935
  cat_label_limit = (
907
- ax.label.max_width
908
- if ax.label.max_width is not None
936
+ ax.labels.max_width
937
+ if ax.labels.max_width is not None
909
938
  else DEFAULT_VL_LABEL_LIMIT
910
939
  )
911
940
  ax_vl["labelPadding"] = cap_padding_to_label_limit(
@@ -935,11 +964,7 @@ def _emit_horizontal(
935
964
  y_scale: dict[str, Any] = {}
936
965
  if ax.scale is not None and ax.scale.padding is not None:
937
966
  y_scale["padding"] = ax.scale.padding
938
- # chart-local band_padding_inner wins over theme bar.padding shorthand
939
- if ax.scale is not None and ax.scale.band_padding_inner is not None:
940
- y_scale["paddingInner"] = ax.scale.band_padding_inner
941
- else:
942
- y_scale["paddingInner"] = bar_mark.padding
967
+ y_scale["paddingInner"] = bar_mark.padding
943
968
 
944
969
  y_enc: dict[str, Any] = {
945
970
  "field": cat_field,
@@ -11,7 +11,11 @@ from dataface.core.compile.models.style.theme import (
11
11
  GeoshapeChartStyle,
12
12
  GeoshapeMarkStyle,
13
13
  )
14
- from dataface.core.render.chart.emitters._channels import channel_to_encoding
14
+ from dataface.core.render.chart._types import VLDict
15
+ from dataface.core.render.chart.emitters._channels import (
16
+ channel_to_encoding,
17
+ gradient_scale_to_vl,
18
+ )
15
19
  from dataface.core.render.chart.geo_tooltip import (
16
20
  build_point_tooltip_fields,
17
21
  validate_map_lookup_key_contract,
@@ -218,18 +222,9 @@ class GeoshapeEmitter:
218
222
  if map_config is not None and map_config.color is not None
219
223
  else None
220
224
  )
221
- _geo_scale: dict[str, Any] = {}
222
- if _geo_gradient is not None:
223
- if isinstance(_geo_gradient.palette, str):
224
- _geo_scale = {"scheme": _geo_gradient.palette}
225
- else:
226
- _geo_scale = {"range": list(_geo_gradient.palette)}
227
- if _geo_gradient.min is not None and _geo_gradient.max is not None:
228
- _geo_scale["domain"] = [_geo_gradient.min, _geo_gradient.max]
229
- elif _geo_gradient.min is not None:
230
- _geo_scale["domainMin"] = _geo_gradient.min
231
- elif _geo_gradient.max is not None:
232
- _geo_scale["domainMax"] = _geo_gradient.max
225
+ _geo_scale: VLDict = (
226
+ gradient_scale_to_vl(_geo_gradient) if _geo_gradient is not None else {}
227
+ )
233
228
  color_enc = {
234
229
  "field": value_field,
235
230
  "type": "quantitative",
@@ -6,7 +6,11 @@ from dataclasses import dataclass
6
6
  from typing import Any
7
7
 
8
8
  from dataface.core.compile.models.chart.resolved.heatmap import ResolvedHeatmapChart
9
- from dataface.core.render.chart.emitters._channels import channel_to_encoding
9
+ from dataface.core.render.chart.emitters._channels import (
10
+ apply_color_legend,
11
+ channel_to_encoding,
12
+ gradient_scale_to_vl,
13
+ )
10
14
  from dataface.core.render.chart.emitters._label_overlap import resolve_axis_x_overlap
11
15
  from dataface.core.render.chart.spec import ChartSpec, RenderBox
12
16
  from dataface.core.render.chart.vl_field_maps import (
@@ -114,7 +118,7 @@ class HeatmapEmitter:
114
118
  y_type = "nominal"
115
119
  # Row labels are the literal per-row values (no d3 format applies
116
120
  # to a nominal field), so an own-side align (resolved from
117
- # axis_y.label.align: inward, or an authored left/right) is
121
+ # axis_y.labels.align: inward, or an authored left/right) is
118
122
  # measured directly against them — same exact-content case
119
123
  # bar.py's categorical own-side gate uses. Only worth the
120
124
  # O(rows) dedup pass when align is actually set — otherwise
@@ -128,7 +132,7 @@ class HeatmapEmitter:
128
132
  if row.get(chart.y) is not None
129
133
  )
130
134
  )
131
- if ay.label.align is not None
135
+ if ay.labels.align is not None
132
136
  else ()
133
137
  )
134
138
  y_axis = measure_axis_to_vl(ay, data, (), category_labels=y_category_labels)
@@ -157,31 +161,21 @@ class HeatmapEmitter:
157
161
  from_slug=True,
158
162
  font=chart.legend.title.font,
159
163
  )
160
- # legend: None suppresses the series legend on heatmap
161
- # (oracle always emits null; also suppresses LegendToggleFeature).
162
- enc["legend"] = None
163
- # Apply color_gradient only when there is a field-bound color encoding.
164
+ # Heatmap's color channel is the whole measure, so it needs a key
165
+ # like every other family — wire the resolved legend style through
166
+ # like bar/line/scatter/pie do. VL renders its native continuous
167
+ # gradient legend for a quantitative field with no extra code.
168
+ apply_color_legend(enc, chart.legend)
169
+ # color_gradient is the cascade-complete gradient (theme/board
170
+ # defaults merged with any chart-local style.color.gradient
171
+ # override) — strictly more complete than a "gradient" mode
172
+ # channel's own ch.scale, which is built from the chart-local
173
+ # gradient dict alone and can be missing a domain the cascade
174
+ # supplies (e.g. board-level min/max). Always prefer it over
175
+ # whatever channel_to_encoding already set.
164
176
  _cg = chart.style.color_gradient
165
177
  if _cg is not None:
166
- _scheme = _cg.palette if isinstance(_cg.palette, str) else None
167
- _range = (
168
- list(_cg.palette) if isinstance(_cg.palette, list) else None
169
- )
170
- if "scale" not in enc:
171
- enc["scale"] = {}
172
- if _scheme is not None and "range" not in enc["scale"]:
173
- enc["scale"] = {**enc["scale"], "scheme": _scheme}
174
- elif _range is not None and "range" not in enc["scale"]:
175
- enc["scale"] = {**enc["scale"], "range": _range}
176
- if _cg.min is not None and _cg.max is not None:
177
- enc["scale"] = {
178
- **enc["scale"],
179
- "domain": [_cg.min, _cg.max],
180
- }
181
- elif _cg.min is not None:
182
- enc["scale"] = {**enc["scale"], "domainMin": _cg.min}
183
- elif _cg.max is not None:
184
- enc["scale"] = {**enc["scale"], "domainMax": _cg.max}
178
+ enc["scale"] = gradient_scale_to_vl(_cg)
185
179
  # Tooltip format on color encoding for quantitative heatmap values.
186
180
  if enc.get("type") == "quantitative":
187
181
  fmt = chart.style.tooltip_format
@@ -381,7 +381,7 @@ class LineEmitter:
381
381
  sub_layers = emit_line_layer(
382
382
  style.line_mark,
383
383
  style.point_mark,
384
- style.background,
384
+ chart.background,
385
385
  style.single_series_fill,
386
386
  has_color_enc,
387
387
  top_encoding["color"] if has_color_enc else {},
@@ -416,7 +416,7 @@ class LineEmitter:
416
416
  chart_id=chart.id,
417
417
  axis_y=style.axis_y,
418
418
  tooltip_format=style.tooltip_format,
419
- background=style.background,
419
+ background=chart.background,
420
420
  single_series_fill=style.single_series_fill,
421
421
  legend=chart.legend,
422
422
  config=build_palette_config(chart.palette),
@@ -7,7 +7,6 @@ from decimal import Decimal
7
7
  from typing import Any
8
8
 
9
9
  from dataface.core.compile.models.chart.resolved.scatter import ResolvedScatterChart
10
- from dataface.core.compile.models.style.theme import ScaleStyle
11
10
  from dataface.core.render.chart.emitters._cartesian import (
12
11
  apply_domain_headroom_bounds,
13
12
  build_palette_config,
@@ -23,24 +22,14 @@ from dataface.core.render.chart.emitters._layers import emit_scatter_layer
23
22
  from dataface.core.render.chart.spec import ChartSpec, RenderBox
24
23
  from dataface.core.render.chart.type_inference import _utc_time_label_expr
25
24
  from dataface.core.render.chart.vl_field_maps import (
26
- SCALE_ENCODING_FIELD_MAP,
27
- SCALE_FIELD_MAP,
28
25
  axis_to_vl,
29
- map_fields,
26
+ emit_resolved_scale_vl,
30
27
  measure_axis_to_vl,
31
28
  )
32
29
  from dataface.core.text.case import format_display_text
33
30
  from dataface.core.text.format_d3 import is_time_format
34
31
 
35
32
 
36
- def _build_axis_scale(scale: ScaleStyle) -> dict[str, Any]:
37
- """Build the VL scale dict from board-level scale only."""
38
- out: dict[str, Any] = {}
39
- out.update(map_fields(scale, SCALE_FIELD_MAP))
40
- out.update(map_fields(scale, SCALE_ENCODING_FIELD_MAP))
41
- return out
42
-
43
-
44
33
  def _suppress_zero_for_scatter_y(data: list[dict[str, Any]], y_field: str) -> bool:
45
34
  """Return True when scatter y-axis zero should be suppressed (ratio > 0.25).
46
35
 
@@ -82,16 +71,10 @@ class ScatterEmitter:
82
71
  # Scatter x is always quantitative (continuous). The strategy resolver
83
72
  # short-circuits to allow+angle=0 for continuous x; mirror that here
84
73
  # by pinning labelAngle explicitly so VL doesn't auto-rotate.
85
- if ax.label.angle is None:
74
+ if ax.labels.angle is None:
86
75
  x_axis["labelAngle"] = 0.0
87
- x_scale = (
88
- _build_axis_scale(chart.style.scale)
89
- if chart.style.scale is not None
90
- else {}
91
- )
92
- if ax.scale is not None:
93
- x_scale.update(map_fields(ax.scale, SCALE_FIELD_MAP))
94
- x_scale.update(map_fields(ax.scale, SCALE_ENCODING_FIELD_MAP))
76
+ x_scale = emit_resolved_scale_vl(chart.style.scale, include_x_only=True)
77
+ x_scale.update(emit_resolved_scale_vl(ax.scale, include_x_only=True))
95
78
  # Use axis title font to match oracle (effective.axis_x.title.font).
96
79
  x_enc: dict[str, Any] = {
97
80
  "field": chart.x,
@@ -130,14 +113,8 @@ class ScatterEmitter:
130
113
  if ay.tick_values:
131
114
  y_axis["values"] = list(ay.tick_values)
132
115
 
133
- y_scale = (
134
- _build_axis_scale(chart.style.scale)
135
- if chart.style.scale is not None
136
- else {}
137
- )
138
- if ay.scale is not None:
139
- y_scale.update(map_fields(ay.scale, SCALE_FIELD_MAP))
140
- y_scale.update(map_fields(ay.scale, SCALE_ENCODING_FIELD_MAP))
116
+ y_scale = emit_resolved_scale_vl(chart.style.scale)
117
+ y_scale.update(emit_resolved_scale_vl(ay.scale))
141
118
  if y_type == "quantitative" and "domain" not in y_scale:
142
119
  # Exact headroom-applied bounds baked at resolve(); an authored
143
120
  # `domain` (mapped above) always wins outright.
@@ -234,7 +211,7 @@ class ScatterEmitter:
234
211
  chart_id=chart.id,
235
212
  axis_y=chart.style.axis_y,
236
213
  tooltip_format=chart.style.tooltip_format,
237
- background=chart.style.background,
214
+ background=chart.background,
238
215
  single_series_fill=chart.style.single_series_fill,
239
216
  legend=chart.legend,
240
217
  config=config,
@@ -165,7 +165,8 @@ def _domain_reaches(
165
165
  percent-format chart whose data stays well below 1.0.
166
166
  """
167
167
  scale = chart.style.axis_y.scale # type: ignore[union-attr]
168
- domain = scale.domain if scale is not None else None
168
+ _sc_cont_bv = scale.continuous if scale is not None else None
169
+ domain = _sc_cont_bv.domain if _sc_cont_bv is not None else None
169
170
  if (
170
171
  domain is not None
171
172
  and len(domain) >= 2
@@ -261,15 +262,17 @@ class BaselineFeature:
261
262
  # log domain cannot represent — Vega-Lite's entire axis rendering
262
263
  # breaks (empirically: every tick label vanishes), not just this layer.
263
264
  axis_y_scale = chart.style.axis_y.scale
264
- if axis_y_scale is not None and axis_y_scale.type == "log":
265
+ _bsl_cont = axis_y_scale.continuous if axis_y_scale is not None else None
266
+ if _bsl_cont is not None and _bsl_cont.type == "log":
265
267
  return
266
268
  # Determine whether the rule should fire.
267
269
  if isinstance(chart, ResolvedBarChart):
268
270
  should_fire = True
269
271
  # An explicit measure-axis domain that excludes 0 suppresses the rule.
270
272
  _scale = chart.style.axis_y.scale
271
- if _scale is not None and _scale.domain is not None:
272
- _d = _scale.domain
273
+ _bsl_cont2 = _scale.continuous if _scale is not None else None
274
+ if _bsl_cont2 is not None and _bsl_cont2.domain is not None:
275
+ _d = _bsl_cont2.domain
273
276
  if (
274
277
  len(_d) >= 2
275
278
  and all(isinstance(v, (int, float)) for v in _d)
@@ -285,7 +288,8 @@ class BaselineFeature:
285
288
  if measure_field is False:
286
289
  return
287
290
  scale = chart.style.axis_y.scale
288
- zero_setting = scale.zero if scale is not None else None
291
+ _bsl_cont3 = scale.continuous if scale is not None else None
292
+ zero_setting = _bsl_cont3.zero if _bsl_cont3 is not None else None
289
293
  if zero_setting is False:
290
294
  # The straddle check must cover every mark sharing this SAME
291
295
  # y scale, not just the base series' own values — apply()
@@ -335,12 +339,14 @@ class BaselineFeature:
335
339
 
336
340
  if measure_field is False:
337
341
  return
338
-
339
342
  # Read zero style from the chart's own baked axis cascade (style.axis_y),
340
343
  # not board-level style — a chart-local axis patch has to win.
341
344
  if not chart.style.axis_y.grid.visible:
342
345
  return
343
346
  zero_style = chart.style.axis_y.grid.zero
347
+ assert (
348
+ zero_style is not None
349
+ ), "zero grid style must be resolved before emitting the baseline rule"
344
350
  rule = _full_rule_at(
345
351
  0,
346
352
  axis=rule_axis,
@@ -358,7 +364,8 @@ class BaselineFeature:
358
364
  assert isinstance(chart, (ResolvedBarChart, ResolvedAreaChart))
359
365
  # Same log-domain incompatibility as _insert_zero_rule's datum:0 guard.
360
366
  axis_y_scale = chart.style.axis_y.scale
361
- if axis_y_scale is not None and axis_y_scale.type == "log":
367
+ _top_cont = axis_y_scale.continuous if axis_y_scale is not None else None
368
+ if _top_cont is not None and _top_cont.type == "log":
362
369
  return
363
370
  rule_axis = (
364
371
  "x"
@@ -369,6 +376,9 @@ class BaselineFeature:
369
376
  if measure_field is False:
370
377
  return
371
378
  zero_style = chart.style.axis_y.grid.zero
379
+ assert (
380
+ zero_style is not None
381
+ ), "zero grid style must be resolved before emitting top rules"
372
382
  # Horizontal bars put the measure on x, so the 0%/100% datum rules anchor
373
383
  # on x — not the categorical y axis (mirrors the zero rule).
374
384
  for datum in (0, 1):
@@ -392,10 +402,10 @@ class BaselineFeature:
392
402
  ) -> None:
393
403
  if not isinstance(chart, (ResolvedLineChart, ResolvedAreaChart)):
394
404
  return
395
- # axis_y.format is the D3 spec resolved from the face+theme cascade.
405
+ # axis_y.labels.format is the D3 spec resolved from the face+theme cascade.
396
406
  # Check it first — it converts Dataface aliases ("percent_whole") to
397
407
  # their literal D3 form (".0%"), which always contains "%".
398
- ax_fmt = chart.style.axis_y.format
408
+ ax_fmt = chart.style.axis_y.labels.format
399
409
  if not ((ax_fmt and "%" in ax_fmt) or _is_percent_format(chart.format)):
400
410
  return
401
411
  # grid.visible=False suppresses the unity rule (mirrors zero-rule gate).
@@ -418,6 +428,9 @@ class BaselineFeature:
418
428
  ):
419
429
  return
420
430
  zero_style = chart.style.axis_y.grid.zero
431
+ assert (
432
+ zero_style is not None
433
+ ), "zero grid style must be resolved before emitting unity rule"
421
434
  _insert_rule(
422
435
  spec,
423
436
  _full_rule_at(