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
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
6
+
5
7
  from dataface.core.fonts import (
6
8
  SOURCE_CODE_PRO_FONT_FAMILY,
7
9
  SOURCE_SERIF_4_FONT_FAMILY,
@@ -43,6 +45,32 @@ def get_font_measurer(
43
45
  return _load_measurer(get_font_path(font_family, numeric=numeric))
44
46
 
45
47
 
48
+ def suffix_reservation_slots(suffix: str, font_family: str) -> int:
49
+ """Digit-width slots a magnitude suffix needs against ``font_family``.
50
+
51
+ ``ceil(suffix_advance / digit_advance)`` — not character count. A
52
+ suffix's advance is not an integer multiple of the digit width even in a
53
+ tabular face, so the slot count is quantised and leaves a sub-digit
54
+ residual: the reservation makes a column scan, it does not make the
55
+ digits share an exact x. Character count under-reserves ``mn``: its
56
+ advance is 2.26 digit-widths, so a 2-character reservation would leave
57
+ the suffix overrunning the column by a quarter digit.
58
+
59
+ Both digit and suffix are measured through
60
+ ``get_font_measurer(font_family, numeric=True)`` — the same numeric-face
61
+ resolution every render call site uses, which always resolves to the
62
+ tabular face digits actually paint in regardless of the surrounding body
63
+ font, and degrades an unvendored family the same way every other numeral
64
+ measurement does.
65
+ """
66
+ if not suffix:
67
+ return 0
68
+ measurer = get_font_measurer(font_family, numeric=True)
69
+ digit_advance = measurer.measure("0", 1.0)
70
+ suffix_advance = measurer.measure(suffix, 1.0)
71
+ return math.ceil(suffix_advance / digit_advance)
72
+
73
+
46
74
  def css_weight_to_axis(weight: str | float) -> float:
47
75
  """Convert a CSS ``font-weight`` value to a variable-font ``wght`` axis value."""
48
76
  if isinstance(weight, (int, float)):
dataface/core/fonts.py CHANGED
@@ -151,7 +151,7 @@ FONT_REGISTRY: tuple[VendoredFace, ...] = (
151
151
  web_file="NotoColorEmoji-Regular.woff2",
152
152
  unicode_range=_EMOJI_UNICODE_RANGES,
153
153
  ),
154
- # No web_file below this line. See VendoredFace.web_file.
154
+ # Source Code Pro is the only measure-only row. See VendoredFace.web_file.
155
155
  VendoredFace(
156
156
  family=SOURCE_CODE_PRO_FONT_FAMILY,
157
157
  style="normal",
@@ -164,16 +164,19 @@ FONT_REGISTRY: tuple[VendoredFace, ...] = (
164
164
  VendoredFace(
165
165
  family=DFT_SERIF_OLDSTYLE_TABULAR_FONT_FAMILY,
166
166
  style="normal",
167
- # Vendored and registered for ReportLab, but no built-in theme emits it; it is
168
- # reachable only if an author names it in a theme font stack.
167
+ # No built-in theme emits either oldstyle face; both are reachable by naming
168
+ # them in a theme font stack. That is why they are served rather than
169
+ # measure-only — selecting oldstyle figures through `font-feature-settings`
170
+ # instead does not survive static export (see fonts/README.md), so the family
171
+ # is the only route, and a family the browser cannot download is not a route.
169
172
  measure_file="DFTSerifOldstyleTabular-Regular.ttf",
170
- web_file=None,
173
+ web_file="DFTSerifOldstyleTabular-Regular.woff2",
171
174
  ),
172
175
  VendoredFace(
173
176
  family=DFT_SERIF_OLDSTYLE_PROPORTIONAL_FONT_FAMILY,
174
177
  style="normal",
175
178
  measure_file="DFTSerifOldstyleProportional-Regular.ttf",
176
- web_file=None,
179
+ web_file="DFTSerifOldstyleProportional-Regular.woff2",
177
180
  ),
178
181
  )
179
182
 
@@ -6,12 +6,18 @@ open-source components: DbtSchemaSource, LayeredSchemaResolver, schema
6
6
  search, and the HTML inspect templates.
7
7
  """
8
8
 
9
- from dataface.core.inspect.renderer import (
10
- InspectProfileCompileError,
11
- render_inspect_dashboard,
12
- resolve_source_name,
13
- validate_inspect_variables,
14
- )
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from dataface.core.inspect.renderer import (
16
+ InspectProfileCompileError,
17
+ render_inspect_dashboard,
18
+ resolve_source_name,
19
+ validate_inspect_variables,
20
+ )
15
21
 
16
22
  # Static list of available inspect templates.
17
23
  # Template files live in dataface/core/inspect/templates/{name}.yml
@@ -32,3 +38,37 @@ __all__ = [
32
38
  "validate_inspect_variables",
33
39
  "INSPECT_TEMPLATES",
34
40
  ]
41
+
42
+ # Submodule (sources/, db_types, etc.) imports run this __init__ first, so
43
+ # these re-exports must not eagerly pull in renderer's (and transitively
44
+ # core.compile's) heavy import chain just to expose INSPECT_TEMPLATES.
45
+ _LAZY_ATTRS: dict[str, tuple[str, str]] = {
46
+ "InspectProfileCompileError": (
47
+ "dataface.core.inspect.renderer",
48
+ "InspectProfileCompileError",
49
+ ),
50
+ "render_inspect_dashboard": (
51
+ "dataface.core.inspect.renderer",
52
+ "render_inspect_dashboard",
53
+ ),
54
+ "resolve_source_name": ("dataface.core.inspect.renderer", "resolve_source_name"),
55
+ "validate_inspect_variables": (
56
+ "dataface.core.inspect.renderer",
57
+ "validate_inspect_variables",
58
+ ),
59
+ }
60
+
61
+
62
+ def __getattr__(name: str) -> Any:
63
+ try:
64
+ module_path, attr_name = _LAZY_ATTRS[name]
65
+ except KeyError:
66
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
67
+ module = importlib.import_module(module_path)
68
+ value = getattr(module, attr_name)
69
+ globals()[name] = value # cache: repeated access is O(1) and `is` identity holds
70
+ return value
71
+
72
+
73
+ def __dir__() -> list[str]:
74
+ return sorted(set(globals()) | set(_LAZY_ATTRS))
dataface/core/project.py CHANGED
@@ -5,7 +5,7 @@ import re
5
5
  from abc import ABC, abstractmethod
6
6
  from collections.abc import Iterable, Iterator
7
7
  from functools import cached_property
8
- from pathlib import PurePosixPath, PureWindowsPath
8
+ from pathlib import PurePath, PurePosixPath, PureWindowsPath
9
9
  from posixpath import normpath
10
10
  from typing import TYPE_CHECKING, Any, NamedTuple
11
11
 
@@ -56,6 +56,18 @@ def assert_relpath(ref: str, anchor: str = ".") -> None:
56
56
  raise ValueError(f"file ref {ref!r} would escape above the project root")
57
57
 
58
58
 
59
+ def posix_relpath(path: PurePath, root: PurePath) -> str:
60
+ """Project-relative POSIX identity for *path* under *root*.
61
+
62
+ ``str(Path.relative_to(...))`` emits the OS-native separator — a no-op on
63
+ POSIX, but backslashes on Windows, which ``assert_relpath`` then rejects.
64
+ File identity is always a POSIX relpath, so normalize with ``as_posix()``.
65
+ *path* and *root* are typed ``PurePath`` (not ``PurePosixPath``) because
66
+ real callers pass OS-native ``Path`` objects from disk.
67
+ """
68
+ return path.relative_to(root).as_posix()
69
+
70
+
59
71
  # Directories skipped when walking the project tree for files.
60
72
  # A host that overrides Project's file-access methods (e.g. CloudManagedProject's
61
73
  # git-blob reader) applies the same filter without duplicating the set.
@@ -903,7 +903,7 @@ def _label_stub_left_layer(
903
903
  ) -> dict[str, Any]:
904
904
  """Left-gutter label layer anchored to the left y-axis tick column.
905
905
 
906
- x = -axis_y.label.padding (same column as left-oriented y-axis tick labels,
906
+ x = -axis_y.labels.padding (same column as left-oriented y-axis tick labels,
907
907
  which extend leftward from x=0 with text-anchor end / align right).
908
908
  mark.limit caps the text width so VL's autosize does not shrink the plot.
909
909
 
@@ -912,7 +912,8 @@ def _label_stub_left_layer(
912
912
  """
913
913
  axis_label_padding = resolved_axis_style(
914
914
  charts_style, "axis_y", "quantitative"
915
- ).label.padding
915
+ ).labels.padding
916
+ assert axis_label_padding is not None, "axis_y.labels.padding unset"
916
917
  x_pixel = -axis_label_padding
917
918
  mark = _label_mark_props(style, align="right")
918
919
  if limit is not None:
@@ -944,7 +945,7 @@ def _label_stub_right_layer(
944
945
  ) -> dict[str, Any]:
945
946
  """Right-cap label layer anchored to the right y-axis tick column.
946
947
 
947
- x = spec_width + axis_y.label.padding (same column as right-oriented y-axis
948
+ x = spec_width + axis_y.labels.padding (same column as right-oriented y-axis
948
949
  tick labels, which extend rightward from that anchor with text-anchor start /
949
950
  align left). mark.limit caps the text width so VL's autosize does not shrink
950
951
  the plot.
@@ -954,7 +955,8 @@ def _label_stub_right_layer(
954
955
  """
955
956
  axis_label_padding = resolved_axis_style(
956
957
  charts_style, "axis_y", "quantitative"
957
- ).label.padding
958
+ ).labels.padding
959
+ assert axis_label_padding is not None, "axis_y.labels.padding unset"
958
960
  x_pixel = spec_width + axis_label_padding
959
961
  mark = _label_mark_props(style, align="left")
960
962
  if limit is not None:
@@ -1042,8 +1044,8 @@ def attach_data_table(
1042
1044
  axis_y_orient: Resolved y-axis orient — ``"right"`` or ``"left"``
1043
1045
  (line charts with right-edge endpoint labels). Determines which label
1044
1046
  emitter fires: right-oriented → label at ``spec_width +
1045
- axis_y.label.padding`` with ``align: left``; left-oriented → label at
1046
- ``-axis_y.label.padding`` with ``align: right``. Required — callers must
1047
+ axis_y.labels.padding`` with ``align: left``; left-oriented → label at
1048
+ ``-axis_y.labels.padding`` with ``align: right``. Required — callers must
1047
1049
  resolve this from the chart's axis_y.orient before calling.
1048
1050
 
1049
1051
  Returns:
@@ -1643,13 +1645,13 @@ def _label_period_filter_expr(
1643
1645
  axis_st = resolved_axis_style(
1644
1646
  resolved_chart_style, "axis_x", x_type or "temporal", ct_axis_x
1645
1647
  )
1646
- raw_label_tu = axis_st.label.time_unit
1648
+ raw_label_tu = axis_st.labels.time_unit
1647
1649
  # Chart-local authored label.time_unit wins when the board-level style
1648
1650
  # has no override — the resolver merges chart-local style into the chart's
1649
1651
  # resolved style object, not back into charts_style, so the fallback reads
1650
1652
  # directly from the chart-local axis_x that was baked into the resolved chart.
1651
1653
  if raw_label_tu is None and chart_local_axis_x is not None:
1652
- raw_label_tu = chart_local_axis_x.label.time_unit
1654
+ raw_label_tu = chart_local_axis_x.labels.time_unit
1653
1655
  format_tu = resolve_label_time_unit(dft_time_unit, raw_label_tu)
1654
1656
  if not format_tu:
1655
1657
  return None
@@ -1670,14 +1672,14 @@ def _label_period_filter_expr(
1670
1672
  if not x_distinct or spec_width / len(x_distinct) >= _LABEL_STRIP_MIN_BAND_PX:
1671
1673
  return None
1672
1674
  safe_field = x_field.replace("'", "\\'")
1673
- font = axis_st.label.font
1675
+ font = axis_st.labels.font
1674
1676
  measurer = get_font_measurer(font.family)
1675
1677
  dates = sorted(datetime.date.fromisoformat(str(v)[:10]) for v in x_distinct)
1676
1678
  band = (spec_width * resolved_chart_style.label_usable_ratio) / len(dates)
1677
1679
  flush_axis = (
1678
1680
  chart_local_axis_x
1679
1681
  if chart_local_axis_x is not None
1680
- and chart_local_axis_x.label.flush is not None
1682
+ and chart_local_axis_x.labels.flush is not None
1681
1683
  else axis_st
1682
1684
  )
1683
1685
  visibility_tu, _ = resolve_temporal_label_visibility(
@@ -1689,8 +1691,8 @@ def _label_period_filter_expr(
1689
1691
  band,
1690
1692
  axis_st.fiscal_year_start_month,
1691
1693
  allow_skip=(
1692
- axis_st.label.overlap.skip
1693
- if axis_st.label.overlap is not None
1694
+ axis_st.labels.overlap.skip
1695
+ if axis_st.labels.overlap is not None
1694
1696
  else False
1695
1697
  ),
1696
1698
  edge_labels_flushed=temporal_edge_labels_flushed("temporal", flush_axis),
@@ -15,8 +15,10 @@ from typing import Any, NamedTuple
15
15
 
16
16
  from dataface.core.compile.models.chart.authored._annotations import ChartSort
17
17
  from dataface.core.compile.models.style.resolved import ResolvedChartsStyle
18
- from dataface.core.compile.models.style.resolved._base import ResolvedAxisStyle
19
- from dataface.core.compile.models.style.theme import ScaleStyle
18
+ from dataface.core.compile.models.style.resolved._base import (
19
+ ResolvedAxisStyle,
20
+ ResolvedScaleStyle,
21
+ )
20
22
  from dataface.core.compile.style_cascade import (
21
23
  chart_type_axis_patch,
22
24
  resolved_axis_style,
@@ -37,9 +39,8 @@ from dataface.core.render.chart.type_inference import (
37
39
  y_zero_scale,
38
40
  )
39
41
  from dataface.core.render.chart.vl_field_maps import (
40
- SCALE_FIELD_MAP,
41
42
  axis_to_vl,
42
- map_fields,
43
+ emit_resolved_scale_vl,
43
44
  )
44
45
  from dataface.core.render.utils import slug_to_text
45
46
  from dataface.core.text.case import format_display_text
@@ -56,7 +57,7 @@ _CONTINUOUS_VL_TYPES = frozenset({"temporal", "quantitative"})
56
57
 
57
58
 
58
59
  def cartesian_x_scale_domain(
59
- scale: ScaleStyle | None, vl_type: str, chart_id: str
60
+ scale: ResolvedScaleStyle | None, vl_type: str, chart_id: str
60
61
  ) -> VLDict:
61
62
  """Return the VL x-scale ``domain`` entry for an authored cartesian x scale.
62
63
 
@@ -66,7 +67,8 @@ def cartesian_x_scale_domain(
66
67
  a band scale as exactly two categories, collapsing every mark onto the
67
68
  first one, rather than extending a continuous range.
68
69
  """
69
- if scale is None or scale.domain is None:
70
+ _sc_cont = scale.continuous if scale is not None else None
71
+ if _sc_cont is None or _sc_cont.domain is None:
70
72
  return {}
71
73
  if vl_type not in _CONTINUOUS_VL_TYPES:
72
74
  raise ChartDataError.from_code(
@@ -74,7 +76,7 @@ def cartesian_x_scale_domain(
74
76
  chart_id=chart_id,
75
77
  vl_type=vl_type,
76
78
  )
77
- return {"domain": list(scale.domain)}
79
+ return {"domain": list(_sc_cont.domain)}
78
80
 
79
81
 
80
82
  def chart_sort_to_vl(sort: ChartSort | None) -> VLDict | None:
@@ -272,24 +274,24 @@ def min_height_for_horizontal_bar_categories(
272
274
  "ordinal",
273
275
  chart_type_axis_patch(resolved_charts, "bar", "axis_x"),
274
276
  )
275
- label = axis_x.label
277
+ label = axis_x.labels
276
278
  bar_mark = resolved_charts.marks.bar
277
279
  font_size = label.font.size
278
- separation = label.separation
280
+ min_gap = label.min_gap
279
281
  padding = label.padding
280
282
  bar_size = bar_mark.size
281
283
  assert (
282
284
  font_size is not None
283
- ), "axis_x.label.font.size unset — theme cascade must populate it"
285
+ ), "axis_x.labels.font.size unset — theme cascade must populate it"
284
286
  assert (
285
- separation is not None
286
- ), "axis_x.label.separation unset — theme cascade must populate it"
287
+ min_gap is not None
288
+ ), "axis_x.labels.min_gap unset — theme cascade must populate it"
287
289
  assert (
288
290
  padding is not None
289
- ), "axis_x.label.padding unset — theme cascade must populate it"
291
+ ), "axis_x.labels.padding unset — theme cascade must populate it"
290
292
  assert bar_size is not None, "marks.bar.size unset — theme cascade must populate it"
291
293
  label_line = font_size + 2 * padding
292
- band_step = max(bar_size, label_line) + separation
294
+ band_step = max(bar_size, label_line) + min_gap
293
295
  return _HORIZONTAL_BAR_LAYOUT_CHROME_PX + n_categories * band_step
294
296
 
295
297
 
@@ -326,13 +328,14 @@ def build_cartesian_y_encoding(
326
328
 
327
329
  Tick values from ay.tick_values are baked into ay_vl["values"] when present.
328
330
  Authored domain replaces the data-driven zero/domainMin computation, but
329
- still carries type/base/exponent/constant (etc.) via SCALE_FIELD_MAP — a
330
- domain and a scale type are not mutually exclusive (e.g. log needs both).
331
+ still carries type/base/exponent/constant (etc.) via emit_resolved_scale_vl
332
+ — a domain and a scale type are not mutually exclusive (e.g. log needs both).
331
333
 
332
334
  Callers that need a VL ``stack`` key (area charts) mutate the returned dict.
333
335
  """
336
+ _ay_cont = ay.scale.continuous if ay.scale is not None else None
334
337
  authored_domain = numeric_domain_bounds(
335
- ay.scale.domain if ay.scale is not None else None
338
+ _ay_cont.domain if _ay_cont is not None else None
336
339
  )
337
340
  y_ticks: list[float] | None = list(ay.tick_values) if ay.tick_values else None
338
341
  if y_ticks and "values" not in ay_vl:
@@ -340,7 +343,7 @@ def build_cartesian_y_encoding(
340
343
  if authored_domain is not None:
341
344
  y_scale: VLDict = {"domain": list(authored_domain)}
342
345
  if ay.scale is not None:
343
- y_scale.update(map_fields(ay.scale, SCALE_FIELD_MAP))
346
+ y_scale.update(emit_resolved_scale_vl(ay.scale))
344
347
  # An authored domain already IS the exact bounds — "zero" is a
345
348
  # domain-inference hint (VL's "extend the domain to include 0"),
346
349
  # meaningless once the domain is pinned explicitly, and its
@@ -5,10 +5,15 @@ from __future__ import annotations
5
5
  from typing import Any
6
6
 
7
7
  from dataface.core.compile.models.chart.resolved import ResolvedStyleChannel
8
+ from dataface.core.compile.models.primitives import (
9
+ VEGA_SCHEME_NAMES,
10
+ ScaleTargetConfig,
11
+ )
8
12
  from dataface.core.compile.models.style.resolved import (
9
13
  ResolvedAxisStyle,
10
14
  ResolvedLegendStyle,
11
15
  )
16
+ from dataface.core.render.chart._types import VLDict
12
17
  from dataface.core.render.chart.time_unit_detect import (
13
18
  BUCKETED_CALENDAR_UNITS,
14
19
  canonicalize_and_sort_ordinal_x,
@@ -24,6 +29,48 @@ from dataface.core.render.chart.vl_conditions import resolved_channel_to_vl_cond
24
29
  from dataface.core.render.chart.vl_field_maps import axis_to_vl, legend_to_vl
25
30
 
26
31
 
32
+ def gradient_scale_to_vl(scale: ScaleTargetConfig) -> VLDict:
33
+ """Build a VL continuous-scale dict from a resolved gradient ScaleTargetConfig.
34
+
35
+ ``palette`` is either a Vega scheme name (forwarded as VL ``scheme``) or a
36
+ Dataface named palette / explicit color list, resolved to hex ``range``
37
+ stops via ``resolve_palette_stops`` — the same resolution table/KPI
38
+ conditional formatting already use for this field. Forwarding a Dataface
39
+ name straight through as a VL scheme string (the pre-fix behavior) is a
40
+ silent no-op: Vega logs an unrecognized-scheme warning and paints with its
41
+ default coloring instead of erroring, so the chart looks fine while
42
+ ignoring the palette the author asked for.
43
+
44
+ A ``list[float]`` (the opacity/stroke_width channel default) is not a
45
+ color palette and passes straight through as ``range`` unchanged —
46
+ ``resolve_palette_stops`` is color-stop-only.
47
+
48
+ Shared by ``channel_to_encoding``'s gradient mode and the geoshape/heatmap
49
+ chart-style gradient paths (``geo.py``, ``heatmap.py``) so the three
50
+ render sites decide scheme-vs-stops the same way instead of each
51
+ reimplementing the branch.
52
+ """
53
+ palette = scale.palette
54
+ if isinstance(palette, str):
55
+ if palette in VEGA_SCHEME_NAMES:
56
+ vl_scale: VLDict = {"scheme": palette}
57
+ else:
58
+ from dataface.core.render.chart.table_support import ( # noqa: PLC0415
59
+ resolve_palette_stops,
60
+ )
61
+
62
+ vl_scale = {"range": resolve_palette_stops(palette)}
63
+ else:
64
+ vl_scale = {"range": list(palette)}
65
+ if scale.min is not None and scale.max is not None:
66
+ vl_scale["domain"] = [scale.min, scale.max]
67
+ elif scale.min is not None:
68
+ vl_scale["domainMin"] = scale.min
69
+ elif scale.max is not None:
70
+ vl_scale["domainMax"] = scale.max
71
+ return vl_scale
72
+
73
+
27
74
  def gap_fill_ordinal_time(
28
75
  ax: ResolvedAxisStyle,
29
76
  x_field: str,
@@ -105,6 +152,9 @@ def gap_fill_ordinal_time(
105
152
  return canonicalize_and_sort_ordinal_x(data, x_field)
106
153
 
107
154
  dim_fields = [color_field] if color_field else []
155
+ assert (
156
+ ax.fill is not None
157
+ ), "x-axis fill must be resolved before ordinal time-series completion"
108
158
  return complete_ordinal_time_series(
109
159
  data, x_field, time_unit, dim_fields, ax.fill, ax.fiscal_year_start_month
110
160
  )
@@ -167,14 +217,11 @@ def channel_to_encoding(
167
217
 
168
218
  if ch.mode == "gradient":
169
219
  assert ch.scale is not None
170
- gradient_scale: dict[str, Any] = {"range": list(ch.scale.palette)}
171
- if ch.scale.min is not None and ch.scale.max is not None:
172
- gradient_scale["domain"] = [ch.scale.min, ch.scale.max]
173
- elif ch.scale.min is not None:
174
- gradient_scale["domainMin"] = ch.scale.min
175
- elif ch.scale.max is not None:
176
- gradient_scale["domainMax"] = ch.scale.max
177
- enc = {"field": ch.data_field, "type": "quantitative", "scale": gradient_scale}
220
+ enc = {
221
+ "field": ch.data_field,
222
+ "type": "quantitative",
223
+ "scale": gradient_scale_to_vl(ch.scale),
224
+ }
178
225
  if axis_style is not None:
179
226
  ax = axis_to_vl(axis_style)
180
227
  if ax:
@@ -242,6 +289,7 @@ __all__ = [
242
289
  "apply_color_legend",
243
290
  "channel_to_encoding",
244
291
  "field_encoding",
292
+ "gradient_scale_to_vl",
245
293
  "infer_vega_type_from_data",
246
294
  "pin_legend_display_order",
247
295
  ]
@@ -24,6 +24,7 @@ from dataface.core.render.chart.time_unit_detect import (
24
24
  resolve_temporal_label_visibility,
25
25
  )
26
26
  from dataface.core.render.chart.type_inference import infer_vega_type_from_data
27
+ from dataface.core.text.format_d3 import portable_strftime
27
28
 
28
29
 
29
30
  class AxisLabelLayout(NamedTuple):
@@ -63,7 +64,7 @@ def _generic_temporal_labels(
63
64
  "yearquarter": lambda value: f"Q{(value.month - 1) // 3 + 1}",
64
65
  "yearmonth": lambda value: value.strftime("%b"),
65
66
  "yearweek": lambda value: value.strftime("W%V"),
66
- "yearmonthdate": lambda value: value.strftime("%-d %b"),
67
+ "yearmonthdate": lambda value: portable_strftime(value, "%-d %b"),
67
68
  "monthofyear": lambda value: value.strftime("%b"),
68
69
  "dayofweek": lambda value: value.strftime("%a"),
69
70
  "dayofmonth": lambda value: str(value.day),
@@ -94,7 +95,7 @@ def _generic_layout(
94
95
  if _fits_flat(considered_widths, usable_width):
95
96
  return AxisLabelLayout(directive, 0.0, None, 0, "")
96
97
  if overlap.tilt:
97
- angle, _ = _pick_tilt_for_widths(axis.label, considered_widths, usable_width)
98
+ angle, _ = _pick_tilt_for_widths(axis.labels, considered_widths, usable_width)
98
99
  return AxisLabelLayout(directive, angle, None, 0, "")
99
100
  return AxisLabelLayout(directive, 0.0, None, 0, "")
100
101
 
@@ -203,14 +204,16 @@ def _temporal_layout(
203
204
  if encoding_time_unit is None:
204
205
  return AxisLabelLayout("allow", 0.0, None, 0, "")
205
206
 
206
- font = axis.label.font
207
+ font = axis.labels.font
207
208
  measurer = get_font_measurer(font.family)
208
209
  usable_width = chart_width * label_usable_ratio
209
- format_time_unit = resolve_label_time_unit(encoding_time_unit, axis.label.time_unit)
210
+ format_time_unit = resolve_label_time_unit(
211
+ encoding_time_unit, axis.labels.time_unit
212
+ )
210
213
  dates = [datetime.date.fromisoformat(value[:10]) for value in values]
211
214
  if (
212
215
  encoding_time_unit in {"yearweek", "yearmonthdate"}
213
- and axis.label.time_unit in (None, "auto")
216
+ and axis.labels.time_unit in (None, "auto")
214
217
  and bucket_aligned_temporal
215
218
  ):
216
219
  candidates = (
@@ -252,7 +255,7 @@ def _temporal_layout(
252
255
  measurer.measure(str(dates[index].day), font.size) for index in indices
253
256
  ]
254
257
  angle = (
255
- _pick_tilt_for_widths(axis.label, widths, usable_width)[0]
258
+ _pick_tilt_for_widths(axis.labels, widths, usable_width)[0]
256
259
  if overlap.tilt
257
260
  else 0.0
258
261
  )
@@ -266,7 +269,7 @@ def _temporal_layout(
266
269
  elif encoding_time_unit in {
267
270
  "yearweek",
268
271
  "yearmonthdate",
269
- } and axis.label.time_unit in (None, "auto"):
272
+ } and axis.labels.time_unit in (None, "auto"):
270
273
  native_labels = _generic_temporal_labels(
271
274
  values, encoding_time_unit, encoding_time_unit
272
275
  )
@@ -361,14 +364,14 @@ def _temporal_layout(
361
364
  "yearquarter": lambda date: f"Q{(date.month - 1) // 3 + 1}",
362
365
  "yearmonth": lambda date: date.strftime("%b"),
363
366
  "yearweek": lambda date: date.strftime("W%V"),
364
- "yearmonthdate": lambda date: date.strftime("%-d %b"),
367
+ "yearmonthdate": lambda date: portable_strftime(date, "%-d %b"),
365
368
  }
366
369
  widths = [
367
370
  measurer.measure(label_texts[format_time_unit](date), font.size)
368
371
  for date in visible_dates
369
372
  ]
370
373
  if overlap.tilt:
371
- angle, _ = _pick_tilt_for_widths(axis.label, widths, usable_width)
374
+ angle, _ = _pick_tilt_for_widths(axis.labels, widths, usable_width)
372
375
  return AxisLabelLayout(
373
376
  directive, angle, visibility, anchor_index, format_time_unit
374
377
  )
@@ -387,11 +390,11 @@ def resolve_axis_x_overlap(
387
390
  chart_width: float,
388
391
  ) -> AxisLabelLayout:
389
392
  """Return render-local overlap, angle, and temporal visibility choices."""
390
- overlap = axis.label.overlap
393
+ overlap = axis.labels.overlap
391
394
  if overlap is None:
392
- return AxisLabelLayout(None, axis.label.angle, None, 0, "")
393
- if axis.label.angle is not None:
394
- return AxisLabelLayout("allow", axis.label.angle, None, 0, "")
395
+ return AxisLabelLayout(None, axis.labels.angle, None, 0, "")
396
+ if axis.labels.angle is not None:
397
+ return AxisLabelLayout("allow", axis.labels.angle, None, 0, "")
395
398
  if is_horizontal_bar:
396
399
  return AxisLabelLayout("allow", 0.0, None, 0, "")
397
400
  if not x_field or not data:
@@ -420,7 +423,7 @@ def resolve_axis_x_overlap(
420
423
  edge_labels_flushed,
421
424
  )
422
425
 
423
- font = axis.label.font
426
+ font = axis.labels.font
424
427
  measurer = get_font_measurer(font.family)
425
428
  widths = [measurer.measure(value, font.size) for value in values]
426
429
  gap = get_chart_rendering().axis.label_gap_spaces * measurer.measure(" ", font.size)
@@ -428,7 +431,7 @@ def resolve_axis_x_overlap(
428
431
  usable_width = chart_width * label_usable_ratio
429
432
  if overlap.tilt and not _fits_flat(widths, usable_width):
430
433
  angle, _ = _pick_tilt(
431
- axis.label,
434
+ axis.labels,
432
435
  x_field,
433
436
  data,
434
437
  chart_width,
@@ -21,7 +21,7 @@ Two tick-content scopes are safe to measure, both covered here:
21
21
 
22
22
  1. **Baked** — ``tick_values`` + a concrete ``format`` (quantitative axes on
23
23
  a theme that sets ``axis.ticks.count``). Dataface already knows the exact
24
- values VL will render (``axis.values`` is emitted explicitly); measuring
24
+ values VL will render (``axis.scale.values`` is emitted explicitly); measuring
25
25
  is exact, not an estimate. ``quantitative_tick_labels``.
26
26
  2. **Unbaked** — no ``tick_values`` (e.g. the ``stark``/``plain`` themes,
27
27
  which leave ``axis.ticks.count`` unset so VL picks its own "nice" ticks).
@@ -64,7 +64,7 @@ _BREATHING_ROOM_PX = 4.0
64
64
  DEFAULT_VL_TICK_SIZE: float = 5.0
65
65
 
66
66
  # Vega-Lite's own default axis.labelLimit when the theme leaves
67
- # axis.label.max_width unset — the pixel width VL truncates a rendered label
67
+ # axis.labels.max_width unset — the pixel width VL truncates a rendered label
68
68
  # to (with an ellipsis) before this module ever measures it.
69
69
  DEFAULT_VL_LABEL_LIMIT: float = 180.0
70
70
 
@@ -101,7 +101,7 @@ def measured_label_padding(
101
101
 
102
102
  Returns 0.0 for an empty label list (nothing to reserve for). Callers
103
103
  resolve VL's own tickSize default (``DEFAULT_VL_TICK_SIZE``) themselves
104
- when the theme leaves ``axis.ticks.size`` unset.
104
+ when the theme leaves ``axis.ticks.length`` unset.
105
105
  """
106
106
  if not labels:
107
107
  return 0.0
@@ -117,7 +117,7 @@ def cap_padding_to_label_limit(
117
117
 
118
118
  ``measured_label_padding`` sizes the gutter to the widest label's full,
119
119
  untruncated text width. Vega-Lite truncates any rendered label wider than
120
- ``labelLimit`` (``axis.label.max_width``, or its own ``DEFAULT_VL_LABEL_LIMIT``
120
+ ``labelLimit`` (``axis.labels.max_width``, or its own ``DEFAULT_VL_LABEL_LIMIT``
121
121
  when unset) to that width plus an ellipsis — so reserving more than
122
122
  ``label_limit + tick_size + breathing_room`` leaves dead gutter space no
123
123
  rendered label ever fills. Capping the final padding value is equivalent
@@ -282,7 +282,7 @@ def _emit_multi_metric_area(
282
282
  style.area_mark,
283
283
  style.line_mark,
284
284
  style.point_mark,
285
- style.background,
285
+ chart.background,
286
286
  style.single_series_fill,
287
287
  True,
288
288
  wide.color,
@@ -344,7 +344,7 @@ class AreaEmitter:
344
344
  style.area_mark,
345
345
  style.line_mark,
346
346
  style.point_mark,
347
- style.background,
347
+ chart.background,
348
348
  style.single_series_fill,
349
349
  color_ch is not None,
350
350
  top_encoding["color"] if color_ch is not None else {},
@@ -380,7 +380,7 @@ class AreaEmitter:
380
380
  chart_id=chart.id,
381
381
  axis_y=style.axis_y,
382
382
  tooltip_format=style.tooltip_format,
383
- background=style.background,
383
+ background=chart.background,
384
384
  single_series_fill=style.single_series_fill,
385
385
  legend=chart.legend,
386
386
  config=build_palette_config(chart.palette),