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
@@ -12,7 +12,7 @@ import re
12
12
  from dataclasses import dataclass
13
13
  from typing import TYPE_CHECKING, Any
14
14
 
15
- from dataface.core.compile.colors import (
15
+ from dataface.core.colors import (
16
16
  is_sanitizable_color,
17
17
  sanitize_color,
18
18
  )
@@ -23,7 +23,9 @@ from dataface.core.compile.models.chart.authored import (
23
23
  from dataface.core.compile.models.primitives import FontStyle
24
24
  from dataface.core.compile.models.style.authored import (
25
25
  PaginationConfig,
26
+ fill_table_column_defaults,
26
27
  )
28
+ from dataface.core.compile.resolve import infer_pivot_measure_names
27
29
 
28
30
  if TYPE_CHECKING:
29
31
  from dataface.core.compile.models.chart.authored import (
@@ -71,7 +73,6 @@ from dataface.core.render.chart.table_support import (
71
73
  is_total_role,
72
74
  measure_column_demands,
73
75
  measure_column_word_floors,
74
- parse_table_column_configs,
75
76
  resolve_cell_conditional_styles,
76
77
  resolve_cell_glyph,
77
78
  resolve_cell_glyph_from_overrides,
@@ -2489,6 +2490,7 @@ def _render_pagination_controls(
2489
2490
  def _transpose_data_for_render(
2490
2491
  formats: dict[str, str] | None,
2491
2492
  columns: dict[str, TableColumnConfig] | None,
2493
+ column_defaults: TableColumnDefaultsConfig | None,
2492
2494
  data: list[dict[str, Any]],
2493
2495
  ) -> tuple[list[dict[str, Any]], dict[str, TableColumnConfig]]:
2494
2496
  """Pivot a single wide row into N (label, value) rows for the normal renderer.
@@ -2498,6 +2500,15 @@ def _transpose_data_for_render(
2498
2500
  - pivoted_columns maps "__metric__" (40% width) and "__value__" (60%, right-align)
2499
2501
  with "Metric" / "Value" header labels.
2500
2502
 
2503
+ ``columns`` (the resolved, source-column-keyed mapping — column_defaults
2504
+ already baked in there for the *source* columns) supplies each row's
2505
+ label/format only. The two output columns below are a render-native key
2506
+ space — they don't exist until this transform runs — so ``column_defaults``
2507
+ is applied to them here via ``fill_table_column_defaults``, filling only
2508
+ whichever fields "Metric"/"Value"/40%-width/right-align don't already
2509
+ set — "__value__" has no width of its own, so ``column_defaults.width``
2510
+ does pin it if authored.
2511
+
2501
2512
  Raises ChartDataError when data does not have exactly one row.
2502
2513
  """
2503
2514
  if len(data) != 1:
@@ -2525,8 +2536,12 @@ def _transpose_data_for_render(
2525
2536
  pivoted.append({"__metric__": label, "__value__": formatted})
2526
2537
 
2527
2538
  pivoted_columns: dict[str, TableColumnConfig] = {
2528
- "__metric__": TableColumnConfig(label="Metric", width="40%"),
2529
- "__value__": TableColumnConfig(label="Value", align="right"),
2539
+ "__metric__": fill_table_column_defaults(
2540
+ TableColumnConfig(label="Metric", width="40%"), column_defaults
2541
+ ),
2542
+ "__value__": fill_table_column_defaults(
2543
+ TableColumnConfig(label="Value", align="right"), column_defaults
2544
+ ),
2530
2545
  }
2531
2546
  return pivoted, pivoted_columns
2532
2547
 
@@ -2564,7 +2579,9 @@ def _render_table_svg_core(
2564
2579
  tc = table_style.table
2565
2580
 
2566
2581
  # Chart-level pivot (rows/columns/values channels): reshape long → wide.
2567
- # Skip when data is empty — fall through to the "No data" empty-state below.
2582
+ # Called unconditionally — a flat table (no pivot_columns) and empty data
2583
+ # both pass straight through, so the layout sizer can call the same way and
2584
+ # can't disagree with us about which shapes reshape.
2568
2585
  # groups is None for flat tables and single-dim single-measure pivot;
2569
2586
  # list-of-levels otherwise (triggers N-row header rendering below).
2570
2587
  # row_role_spec is hoisted here (ahead of the row loop that also reads it) so
@@ -2572,16 +2589,13 @@ def _render_table_svg_core(
2572
2589
  # row. TableRowStyle.role defaults to None — resolve_row_role/is_total_role
2573
2590
  # both accept None (treat every row as "value"), so unset is a valid case.
2574
2591
  row_role_spec = tc.row.role
2575
- _pivot_groups: list[list[tuple[str, int, int]]] | None = None
2576
- _pivot_effective_values: list[str] = []
2577
- if pivot_columns and data:
2578
- data, _pivot_groups, _pivot_effective_values = pivot_table_data(
2579
- data,
2580
- rows=rows or [],
2581
- columns=pivot_columns,
2582
- values=values,
2583
- row_role_spec=row_role_spec,
2584
- )
2592
+ data, _pivot_groups, _pivot_effective_values = pivot_table_data(
2593
+ data,
2594
+ rows=rows or [],
2595
+ columns=pivot_columns,
2596
+ values=values,
2597
+ row_role_spec=row_role_spec,
2598
+ )
2585
2599
 
2586
2600
  # Build colors dict from chart-local TableChartStyle (which already has
2587
2601
  # board-level values cascaded in). board_style is only needed for font
@@ -2730,14 +2744,9 @@ def _render_table_svg_core(
2730
2744
  subtitle_lines = list(title_block.subtitle_lines)
2731
2745
  subtitle_font_size = title_block.subtitle_font_size
2732
2746
 
2733
- # Pass query column names so column_defaults can apply to all inferred columns
2734
- # when no explicit style.columns is provided (the "no columns" authoring shape).
2735
- query_columns = list(data[0].keys()) if data else None
2736
- column_configs = parse_table_column_configs(
2737
- columns_promoted,
2738
- column_defaults_promoted,
2739
- query_columns=query_columns,
2740
- )
2747
+ # columns_promoted is already the complete, final per-column config
2748
+ # mapping (defaults + explicit overrides + FK links baked in at resolve).
2749
+ column_configs: dict[str, TableColumnConfig] = dict(columns_promoted or {})
2741
2750
 
2742
2751
  # Tables read conditional_formatting rules directly at render time — no
2743
2752
  # internal lowering into column configs.
@@ -2764,8 +2773,13 @@ def _render_table_svg_core(
2764
2773
  if existing is not None:
2765
2774
  _expanded_cc[leaf] = existing
2766
2775
  else:
2767
- _expanded_cc[leaf] = TableColumnConfig(
2768
- label=slug_to_text(_display_label)
2776
+ # Measure not explicitly authored — its key space (this leaf)
2777
+ # only exists after the pivot transform above, so resolve
2778
+ # excluded it from column_configs; apply column_defaults here.
2779
+ _expanded_cc[leaf] = fill_table_column_defaults(
2780
+ None,
2781
+ column_defaults_promoted,
2782
+ fallback_label=slug_to_text(_display_label),
2769
2783
  )
2770
2784
  column_configs = {**column_configs, **_expanded_cc}
2771
2785
  # Remap column_when_rules: measure-name key → leaf keys.
@@ -2776,14 +2790,37 @@ def _render_table_svg_core(
2776
2790
  if _measure_part and _measure_part in column_when_rules:
2777
2791
  _expanded_cwr[leaf] = column_when_rules[_measure_part]
2778
2792
  column_when_rules = {**column_when_rules, **_expanded_cwr}
2793
+ elif pivot_columns and data:
2794
+ # Single-dim single-measure pivot: leaf keys are the bare pivoted
2795
+ # values themselves (no separator, no measure-name lookup needed) —
2796
+ # but that key space still only exists after the pivot transform
2797
+ # above, so any key not already explicitly authored gets
2798
+ # column_defaults applied here, same as the multi-measure case.
2799
+ for leaf in data[0]:
2800
+ if leaf == row_role_spec or leaf in column_configs:
2801
+ continue
2802
+ column_configs[leaf] = fill_table_column_defaults(
2803
+ None, column_defaults_promoted, fallback_label=slug_to_text(leaf)
2804
+ )
2779
2805
 
2780
2806
  header_overflow = resolve_header_overflow(table_config, header_overflow_promoted)
2781
2807
 
2782
2808
  # Prefer explicit configured columns so helper/style columns can stay hidden.
2783
2809
  # Dict keys preserve insertion order, so authored column order is honored.
2784
- # For multi-measure pivot, columns_promoted is keyed by measure names —
2785
- # not leaf keys — so we always derive columns from data keys in pivot mode.
2786
- if columns_promoted and _pivot_groups is None:
2810
+ #
2811
+ # In pivot mode, columns_promoted's keys are the real leaf/pivoted-value
2812
+ # keys ONLY for an explicitly-authored single-dim single-measure pivot
2813
+ # (authors key style.columns by those bare values directly) — checked via
2814
+ # the subset test below. For every other pivot shape (multi-measure/dim,
2815
+ # or resolve's inferred row/pivot-dimension columns) columns_promoted is
2816
+ # keyed by measure or dimension names that never match the actual
2817
+ # post-transform keys, so the visible list must come from data instead.
2818
+ # An empty `data` means the transform above never ran (nothing to check
2819
+ # against) — honor the authored list so an authored table still shows
2820
+ # its declared header in the empty-state render.
2821
+ if columns_promoted and (
2822
+ not pivot_columns or not data or set(columns_promoted).issubset(data[0])
2823
+ ):
2787
2824
  columns = list(columns_promoted.keys())
2788
2825
  else:
2789
2826
  columns = list(data[0].keys()) if data else []
@@ -3397,7 +3434,9 @@ def render_table_svg(
3397
3434
  columns = chart.columns
3398
3435
 
3399
3436
  if chart.style.table.transpose and data:
3400
- data, columns = _transpose_data_for_render(chart.style.formats, columns, data)
3437
+ data, columns = _transpose_data_for_render(
3438
+ chart.style.formats, columns, chart.column_defaults, data
3439
+ )
3401
3440
 
3402
3441
  return _render_table_svg_core(
3403
3442
  chart_id=chart.id,
@@ -3479,9 +3518,8 @@ def _validate_pivot_fields(
3479
3518
  f" carries the per-row role — observed keys: {observed}"
3480
3519
  )
3481
3520
 
3482
- claimed = set(rows) | set(columns) | ({row_role_spec} if row_role_spec else set())
3483
- effective_values: list[str] = (
3484
- values if values is not None else [k for k in all_keys if k not in claimed]
3521
+ effective_values = infer_pivot_measure_names(
3522
+ all_keys, rows, columns, values, row_role_spec
3485
3523
  )
3486
3524
  for v in effective_values:
3487
3525
  if v not in all_keys:
@@ -3548,12 +3586,18 @@ def pivot_table_data(
3548
3586
  data: list[dict[str, Any]],
3549
3587
  *,
3550
3588
  rows: list[str],
3551
- columns: list[str],
3589
+ columns: list[str] | None,
3552
3590
  values: list[str] | None,
3553
3591
  row_role_spec: str | None = None,
3554
3592
  ) -> tuple[list[dict[str, Any]], list[list[tuple[str, int, int]]] | None, list[str]]:
3555
3593
  """Reshape long-form tidy data into a wide pivot matrix.
3556
3594
 
3595
+ Also the single owner of the "is this a pivot?" decision: no ``columns``
3596
+ means a flat table and the rows pass through untouched. The renderer and
3597
+ the layout sizer (``layout_sizing._get_table_height_from_data``) both call
3598
+ unconditionally, so the height the sizer reserves is always the row count
3599
+ the renderer draws.
3600
+
3557
3601
  Args:
3558
3602
  data: Long-form source rows.
3559
3603
  rows: Row-dimension field names (form the composite row key). When
@@ -3562,7 +3606,8 @@ def pivot_table_data(
3562
3606
  column order (mirrors ``values``'s own inference).
3563
3607
  columns: Pivot-column field(s) whose distinct values become column headers.
3564
3608
  Single field → single-dimension pivot. Multiple fields → nested
3565
- multi-dimension pivot (outer dim first, inner dim last).
3609
+ multi-dimension pivot (outer dim first, inner dim last). Empty or
3610
+ ``None`` → not a pivot; ``data`` comes back unchanged.
3566
3611
  values: Measure field names. When ``None``, infers as all query columns
3567
3612
  not in ``rows``, not any column field, and not the role marker,
3568
3613
  preserving query column order.
@@ -3610,8 +3655,8 @@ def pivot_table_data(
3610
3655
  field onto rows or columns." (a total row keys by rows-dim like any
3611
3656
  other, so this covers both detail and total collisions).
3612
3657
  """
3613
- if not data:
3614
- return [], None, []
3658
+ if not columns or not data:
3659
+ return data, None, []
3615
3660
 
3616
3661
  # Both dims omitted → the remaining fields can't be split into rows vs
3617
3662
  # measures without guessing. Validate-and-error rather than degenerate into
@@ -29,6 +29,7 @@ from dataface.core.render.chart.title_overflow import (
29
29
  from dataface.core.render.format_utils import default_number_format, format_value
30
30
  from dataface.core.render.utils import resolve_tone_color, slug_to_text
31
31
  from dataface.core.text.case import CaseValue, apply_case
32
+ from dataface.core.text.format_d3 import portable_strftime
32
33
  from mdsvg.fonts import FontMeasurer
33
34
 
34
35
  # Three-lane numeric rendering constants used by table.py:_compute_lane_positions.
@@ -49,7 +50,6 @@ if TYPE_CHECKING:
49
50
  TableColumnConfig,
50
51
  )
51
52
  from dataface.core.compile.models.primitives import FormatConfig
52
- from dataface.core.compile.models.style.authored import TableColumnDefaultsConfig
53
53
  from dataface.core.compile.models.style.theme import (
54
54
  KpiTonesStyle,
55
55
  TableChartStyle,
@@ -212,62 +212,11 @@ def _format_temporal_value(
212
212
  d = value
213
213
 
214
214
  try:
215
- return d.strftime(format_spec)
215
+ return portable_strftime(d, format_spec)
216
216
  except ValueError as e:
217
217
  raise ValueError(f"Invalid temporal format spec {format_spec!r}: {e}") from e
218
218
 
219
219
 
220
- def _apply_column_defaults(
221
- cfg: TableColumnConfig,
222
- defaults: TableColumnDefaultsConfig,
223
- ) -> TableColumnConfig:
224
- updates = {
225
- name: getattr(defaults, name)
226
- for name in type(defaults).model_fields
227
- if getattr(cfg, name) is None and getattr(defaults, name) is not None
228
- }
229
- return cfg.model_copy(update=updates) if updates else cfg
230
-
231
-
232
- def parse_table_column_configs(
233
- columns_config: dict[str, TableColumnConfig] | None,
234
- defaults: TableColumnDefaultsConfig | None = None,
235
- query_columns: list[str] | None = None,
236
- ) -> dict[str, TableColumnConfig]:
237
- """Apply table-level defaults to the resolved column configurations.
238
-
239
- When column_defaults is set and columns is unset, query_columns is used to
240
- materialize a default config for every query-inferred column — enabling the
241
- "no columns list" authoring shape where uniform presentation is set once at
242
- the table style level.
243
- """
244
- from dataface.core.compile.models.chart.authored import (
245
- SparkConfig,
246
- TableColumnConfig,
247
- )
248
-
249
- # No explicit columns: if column_defaults is set, materialize defaults for
250
- # every query-inferred column so authors need not list columns at all.
251
- if not columns_config:
252
- if defaults and query_columns:
253
- return {
254
- col: _apply_column_defaults(TableColumnConfig(), defaults)
255
- for col in query_columns
256
- }
257
- return {}
258
-
259
- result: dict[str, TableColumnConfig] = {}
260
- for field_name, col_cfg in columns_config.items():
261
- cfg = col_cfg
262
- # Normalize spark string shorthand to SparkConfig so render code can
263
- # use isinstance(cfg.spark, SparkConfig) uniformly.
264
- if isinstance(cfg.spark, str):
265
- cfg = cfg.model_copy(update={"spark": SparkConfig(type=cfg.spark)})
266
- result[field_name] = _apply_column_defaults(cfg, defaults) if defaults else cfg
267
-
268
- return result
269
-
270
-
271
220
  def parse_column_width(
272
221
  width_hint: int | str | None, available_width: float
273
222
  ) -> float | None:
@@ -30,9 +30,10 @@ import re
30
30
  import statistics
31
31
  from typing import Any
32
32
 
33
- from dataface.core.compile.enrich import is_year_shaped
34
33
  from dataface.core.font_measure import FontMeasurer
35
34
  from dataface.core.render.chart.artifacts import ChartRenderData
35
+ from dataface.core.text.format_d3 import portable_strftime
36
+ from dataface.core.utils import is_year_shaped
36
37
 
37
38
  # ISO date: "2024-01-15"
38
39
  _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@@ -227,7 +228,7 @@ def _cadence_token_width(
227
228
  if format_tu == "yearweek":
228
229
  return measurer.measure(d.strftime("W%V"), size)
229
230
  if format_tu == "yearmonthdate":
230
- return measurer.measure(d.strftime("%-d %b"), size)
231
+ return measurer.measure(portable_strftime(d, "%-d %b"), size)
231
232
  raise ValueError(
232
233
  f"_cadence_token_width: {format_tu!r} is not a bucketed calendar grain"
233
234
  )
@@ -21,10 +21,12 @@ the standard path like any other sub-spec.
21
21
  from __future__ import annotations
22
22
 
23
23
  import json
24
+ from collections.abc import Callable
24
25
  from typing import Any
25
26
 
26
27
  from dataface.core.compile.models.style.resolved import ResolvedStyle
27
28
  from dataface.core.compile.vega_lite import VEGA_LITE_SCHEMA_URL
29
+ from dataface.core.render.chart._types import VLDict
28
30
  from dataface.core.render.chart.presentation import (
29
31
  _HREF_FIELD,
30
32
  apply_presentation_defaults,
@@ -411,6 +413,25 @@ def _wrap_vconcat_label_rail(
411
413
  # ---------------------------------------------------------------------------
412
414
 
413
415
 
416
+ def _in_chart_pane(vl: VLDict, stamp: Callable[[VLDict], VLDict]) -> VLDict:
417
+ """Run ``stamp`` on the pane that carries the marks.
418
+
419
+ An endpoint-label rail wraps the chart in a concat: ``hconcat`` for the
420
+ vertical layout (chart first, label pane right), ``vconcat`` for the
421
+ horizontal one (rail first, chart second). Neither VL nor Vega reads a
422
+ top-level ``encoding``, ``transform`` or ``params`` on a concat spec, so
423
+ anything that stamps those has to descend to the chart pane or it lands
424
+ somewhere the renderer never looks.
425
+ """
426
+ if "hconcat" in vl:
427
+ main, *rest = vl["hconcat"]
428
+ return {**vl, "hconcat": [_in_chart_pane(main, stamp), *rest]}
429
+ if "vconcat" in vl:
430
+ rail, main = vl["vconcat"]
431
+ return {**vl, "vconcat": [rail, _in_chart_pane(main, stamp)]}
432
+ return stamp(vl)
433
+
434
+
414
435
  def _apply_structured_tooltip(vl: dict[str, Any], expr: str) -> dict[str, Any]:
415
436
  """Inject the description encoding for the structured tooltip.
416
437
 
@@ -433,11 +454,13 @@ def _apply_structured_tooltip(vl: dict[str, Any], expr: str) -> dict[str, Any]:
433
454
  Vega signal expression (confirmed: identical rendered aria-label content,
434
455
  clean impute groupby with the fix, corrupted path without it).
435
456
 
436
- Endpoint labels (right_pane layout) wrap the layered spec in an ``hconcat``
437
- before this post-processing step runs (``_translate_layered`` calls
438
- ``_wrap_endpoint_labels`` internally) — recurse into the main panel
439
- (``hconcat[0]``) rather than stamping a no-op encoding onto the hconcat
440
- wrapper itself; the label rail (``hconcat[1]``) carries no data marks.
457
+ Endpoint labels wrap the layered spec in an ``hconcat`` (right_pane,
458
+ vertical stacked bar/line/area, chart pane at index 0) or a ``vconcat``
459
+ (top_rail, horizontal stacked bar, chart pane at index 1 — the rail sits
460
+ above it) before this post-processing step runs (``_translate_layered``
461
+ calls ``_wrap_endpoint_labels`` internally) — recurse into the actual
462
+ chart pane rather than stamping a no-op encoding onto the wrapper itself;
463
+ the label pane/rail carries no data marks of its own.
441
464
 
442
465
  A layered spec needs one more override for ``BaselineFeature``'s
443
466
  zero/top/unity rule overlays specifically: they're appended sub-layers
@@ -457,41 +480,47 @@ def _apply_structured_tooltip(vl: dict[str, Any], expr: str) -> dict[str, Any]:
457
480
  channel" convention — the same one ``_full_rule_at`` and
458
481
  ``_isolate_independent_y_axis`` already rely on for ``axis``/``legend``).
459
482
  """
460
- if "hconcat" in vl:
461
- result = dict(vl)
462
- main, *rest = vl["hconcat"]
463
- result["hconcat"] = [_apply_structured_tooltip(main, expr), *rest]
483
+
484
+ def stamp(pane: VLDict) -> VLDict:
485
+ result = dict(pane)
486
+ enc = dict(result.get("encoding") or {})
487
+ enc["description"] = {"value": {"expr": expr}}
488
+ result["encoding"] = enc
489
+ layers = result.get("layer")
490
+ if isinstance(layers, list):
491
+ result["layer"] = [
492
+ (
493
+ {
494
+ **layer,
495
+ "encoding": {
496
+ **(layer.get("encoding") or {}),
497
+ "description": None,
498
+ },
499
+ }
500
+ if "data" in layer
501
+ else layer
502
+ )
503
+ for layer in layers
504
+ ]
464
505
  return result
465
- result = dict(vl)
466
- enc = dict(result.get("encoding") or {})
467
- enc["description"] = {"value": {"expr": expr}}
468
- result["encoding"] = enc
469
- layers = result.get("layer")
470
- if isinstance(layers, list):
471
- result["layer"] = [
472
- (
473
- {
474
- **layer,
475
- "encoding": {**(layer.get("encoding") or {}), "description": None},
476
- }
477
- if "data" in layer
478
- else layer
479
- )
480
- for layer in layers
481
- ]
482
- return result
506
+
507
+ return _in_chart_pane(vl, stamp)
483
508
 
484
509
 
485
510
  def _apply_href_link(vl: dict[str, Any], href_link: str) -> dict[str, Any]:
486
511
  """Inject calculate transform + href encoding for click interactivity."""
487
- result = dict(vl)
488
- transforms = list(result.get("transform") or [])
489
- transforms.append({"calculate": href_link, "as": _HREF_FIELD})
490
- result["transform"] = transforms
491
- enc = dict(result.get("encoding") or {})
492
- enc["href"] = {"field": _HREF_FIELD, "type": "nominal"}
493
- result["encoding"] = enc
494
- return result
512
+
513
+ def stamp(pane: VLDict) -> VLDict:
514
+ result = dict(pane)
515
+ transforms = list(result.get("transform") or [])
516
+ transforms.append({"calculate": href_link, "as": _HREF_FIELD})
517
+ result["transform"] = transforms
518
+ enc = dict(result.get("encoding") or {})
519
+ enc["href"] = {"field": _HREF_FIELD, "type": "nominal"}
520
+ result["encoding"] = enc
521
+ return result
522
+
523
+ return _in_chart_pane(vl, stamp)
495
524
 
496
525
 
497
526
  def _apply_legend_toggle(vl: dict[str, Any], color_field: str) -> dict[str, Any]:
@@ -500,9 +529,12 @@ def _apply_legend_toggle(vl: dict[str, Any], color_field: str) -> dict[str, Any]
500
529
  In a layered spec the param is anchored inside the first data-bearing layer
501
530
  to avoid duplicate Vega signals.
502
531
  """
532
+ if "hconcat" in vl or "vconcat" in vl:
533
+ return _in_chart_pane(vl, lambda pane: _apply_legend_toggle(pane, color_field))
534
+
503
535
  enc = vl.get("encoding")
504
536
  if not isinstance(enc, dict):
505
- return vl # hconcat/vconcat wrappers have no top-level encoding
537
+ return vl
506
538
 
507
539
  color_enc = enc.get("color")
508
540
  if not isinstance(color_enc, dict):
@@ -626,12 +658,14 @@ def assemble_final_vl(
626
658
 
627
659
  Args:
628
660
  spec: ``ChartSpec`` produced by an emitter and mutated by the feature
629
- pipeline. Must carry ``title``/``subtitle``/``title_font`` if
630
- needed (set by ``FaceRenderSession.emit_chart`` from the resolved
631
- chart).
632
- board_style: Fully resolved board-level style. Supplies
633
- ``vega_config`` (baked theme config) and ``charts`` (per-chart
634
- style overlay).
661
+ pipeline. Must carry ``title``/``subtitle``/``title_font``/
662
+ ``background``/``title_style`` (set by
663
+ ``FaceRenderSession.emit_chart`` from the resolved chart —
664
+ ``title`` is the raw jinja-resolved text; title-case is applied
665
+ here from ``title_style.font.case``, VL-presentation only).
666
+ board_style: Fully resolved board-level style. Supplies only
667
+ ``vega_config`` (baked theme config) — unchanged face-level
668
+ presentation, not a per-chart decision.
635
669
 
636
670
  Returns:
637
671
  Vega-Lite JSON-serialisable spec dict with config and background applied.
@@ -641,16 +675,20 @@ def assemble_final_vl(
641
675
  """
642
676
  vl = translate_to_vl(spec)
643
677
 
678
+ assert spec.background is not None and spec.title_style is not None, (
679
+ "ChartSpec.background/title_style must be set by "
680
+ "FaceRenderSession.emit_chart before assemble_final_vl runs"
681
+ )
644
682
  vl = apply_presentation_defaults(
645
683
  vl,
646
- board_style.charts,
684
+ spec.background,
647
685
  board_style.vega_config,
648
686
  )
649
687
 
650
688
  if spec.title or spec.subtitle:
651
689
  from dataface.core.render.chart.spec_builders import set_chart_title
652
690
 
653
- set_chart_title(vl, spec.title, spec.subtitle, style=board_style.charts)
691
+ set_chart_title(vl, spec.title, spec.subtitle, case=spec.title_style.font.case)
654
692
 
655
693
  # config.title.fontSize/fontWeight come from theme YAML as floats; coerce to int.
656
694
  if "config" in vl and "title" in vl["config"]: