dataface 0.1.2__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 (455) hide show
  1. d3_format/__init__.py +14 -0
  2. d3_format/errors.py +19 -0
  3. d3_format/format.py +551 -0
  4. d3_format/spec.py +159 -0
  5. dataface/DATAFACE_SYNTAX.md +1135 -0
  6. dataface/__init__.py +93 -0
  7. dataface/_docs_site.py +20 -0
  8. dataface/_install_hint.py +26 -0
  9. dataface/agent_api/__init__.py +79 -0
  10. dataface/agent_api/_init_templates/__init__.py +0 -0
  11. dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
  12. dataface/agent_api/_init_templates/dataface.yml +15 -0
  13. dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
  14. dataface/agent_api/_init_templates/index.md +24 -0
  15. dataface/agent_api/_paths.py +118 -0
  16. dataface/agent_api/_project_agents_md.py +43 -0
  17. dataface/agent_api/_session_store.py +486 -0
  18. dataface/agent_api/_state.py +28 -0
  19. dataface/agent_api/chat.py +221 -0
  20. dataface/agent_api/dashboards.py +257 -0
  21. dataface/agent_api/describe.py +366 -0
  22. dataface/agent_api/describe_query.py +120 -0
  23. dataface/agent_api/docs/__init__.py +25 -0
  24. dataface/agent_api/docs/_loader.py +292 -0
  25. dataface/agent_api/docs/yaml-reference.md +2757 -0
  26. dataface/agent_api/file_refs.py +118 -0
  27. dataface/agent_api/init.py +126 -0
  28. dataface/agent_api/inspect.py +128 -0
  29. dataface/agent_api/mcp_install.py +170 -0
  30. dataface/agent_api/query.py +274 -0
  31. dataface/agent_api/schema.py +658 -0
  32. dataface/agent_api/schema_search.py +284 -0
  33. dataface/agent_api/search.py +270 -0
  34. dataface/agent_api/skill_install.py +141 -0
  35. dataface/agent_api/skill_render.py +90 -0
  36. dataface/agent_api/skills.py +293 -0
  37. dataface/agent_api/surface_aliases.yaml +128 -0
  38. dataface/agent_api/validate.py +175 -0
  39. dataface/agent_api/validate_query.py +84 -0
  40. dataface/ai/__init__.py +39 -0
  41. dataface/ai/agent.py +139 -0
  42. dataface/ai/context.py +45 -0
  43. dataface/ai/events.py +62 -0
  44. dataface/ai/external_mcp.py +610 -0
  45. dataface/ai/generate_sql.py +96 -0
  46. dataface/ai/llm.py +403 -0
  47. dataface/ai/mcp/__init__.py +51 -0
  48. dataface/ai/mcp/server.py +289 -0
  49. dataface/ai/memories.py +85 -0
  50. dataface/ai/prompts.py +177 -0
  51. dataface/ai/schema_context.py +138 -0
  52. dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
  53. dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
  54. dataface/ai/skills/dashboard-build/SKILL.md +212 -0
  55. dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
  56. dataface/ai/skills/dashboard-design/SKILL.md +182 -0
  57. dataface/ai/skills/dashboard-review/SKILL.md +113 -0
  58. dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
  59. dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
  60. dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
  61. dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
  62. dataface/ai/skills/drill-down-link/SKILL.md +112 -0
  63. dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
  64. dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
  65. dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
  66. dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
  67. dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
  68. dataface/ai/skills/kpi-row/SKILL.md +101 -0
  69. dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
  70. dataface/ai/skills/report-design/SKILL.md +184 -0
  71. dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
  72. dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
  73. dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
  74. dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
  75. dataface/ai/skills/time-series-trend/SKILL.md +93 -0
  76. dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
  77. dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
  78. dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
  79. dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
  80. dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
  81. dataface/ai/tool_schemas.py +132 -0
  82. dataface/ai/tools/__init__.py +312 -0
  83. dataface/ai/yaml_utils.py +57 -0
  84. dataface/cli/__init__.py +3 -0
  85. dataface/cli/_console.py +48 -0
  86. dataface/cli/_error_format.py +83 -0
  87. dataface/cli/_extras.py +190 -0
  88. dataface/cli/_json_output.py +8 -0
  89. dataface/cli/_parsing.py +17 -0
  90. dataface/cli/_version_info.py +56 -0
  91. dataface/cli/commands/__init__.py +3 -0
  92. dataface/cli/commands/_agent_input.py +205 -0
  93. dataface/cli/commands/_agent_server.py +115 -0
  94. dataface/cli/commands/chat.py +645 -0
  95. dataface/cli/commands/describe.py +107 -0
  96. dataface/cli/commands/docs.py +131 -0
  97. dataface/cli/commands/extension.py +179 -0
  98. dataface/cli/commands/init.py +240 -0
  99. dataface/cli/commands/inspect.py +94 -0
  100. dataface/cli/commands/mcp_init.py +167 -0
  101. dataface/cli/commands/query.py +386 -0
  102. dataface/cli/commands/render.py +291 -0
  103. dataface/cli/commands/schema.py +411 -0
  104. dataface/cli/commands/search.py +49 -0
  105. dataface/cli/commands/serve.py +114 -0
  106. dataface/cli/commands/skills.py +133 -0
  107. dataface/cli/commands/skills_init.py +161 -0
  108. dataface/cli/commands/validate.py +63 -0
  109. dataface/cli/main.py +1501 -0
  110. dataface/core/__init__.py +75 -0
  111. dataface/core/compile/__init__.py +244 -0
  112. dataface/core/compile/_jinja_helpers.py +78 -0
  113. dataface/core/compile/channel.py +222 -0
  114. dataface/core/compile/chart_focus.py +101 -0
  115. dataface/core/compile/chart_resolved.py +169 -0
  116. dataface/core/compile/chart_type_detection.py +489 -0
  117. dataface/core/compile/chart_update.py +261 -0
  118. dataface/core/compile/colors.py +64 -0
  119. dataface/core/compile/compiler.py +904 -0
  120. dataface/core/compile/config.py +823 -0
  121. dataface/core/compile/custom_chart_types.py +208 -0
  122. dataface/core/compile/data_table_attachment.py +1287 -0
  123. dataface/core/compile/detect.py +110 -0
  124. dataface/core/compile/errors.py +302 -0
  125. dataface/core/compile/filter_injection.py +319 -0
  126. dataface/core/compile/introspection.py +527 -0
  127. dataface/core/compile/jinja.py +511 -0
  128. dataface/core/compile/labels_env.py +52 -0
  129. dataface/core/compile/markdown.py +154 -0
  130. dataface/core/compile/meta.py +388 -0
  131. dataface/core/compile/models/__init__.py +0 -0
  132. dataface/core/compile/models/chart/__init__.py +0 -0
  133. dataface/core/compile/models/chart/authored.py +2137 -0
  134. dataface/core/compile/models/chart/compiled.py +398 -0
  135. dataface/core/compile/models/config.py +347 -0
  136. dataface/core/compile/models/face/__init__.py +0 -0
  137. dataface/core/compile/models/face/authored.py +659 -0
  138. dataface/core/compile/models/face/compiled.py +522 -0
  139. dataface/core/compile/models/factories.py +201 -0
  140. dataface/core/compile/models/markers.py +40 -0
  141. dataface/core/compile/models/palette.py +36 -0
  142. dataface/core/compile/models/primitives.py +415 -0
  143. dataface/core/compile/models/query/__init__.py +0 -0
  144. dataface/core/compile/models/query/authored.py +246 -0
  145. dataface/core/compile/models/query/compiled.py +710 -0
  146. dataface/core/compile/models/refs.py +137 -0
  147. dataface/core/compile/models/source.py +611 -0
  148. dataface/core/compile/models/style/__init__.py +0 -0
  149. dataface/core/compile/models/style/authored.py +481 -0
  150. dataface/core/compile/models/style/compiled.py +3399 -0
  151. dataface/core/compile/models/style/merged.py +1682 -0
  152. dataface/core/compile/models/theme.py +362 -0
  153. dataface/core/compile/models/variable/__init__.py +0 -0
  154. dataface/core/compile/models/variable/authored.py +254 -0
  155. dataface/core/compile/models/vega_lite/__init__.py +0 -0
  156. dataface/core/compile/models/vega_lite/config.py +510 -0
  157. dataface/core/compile/models/vega_lite/contracts.py +171 -0
  158. dataface/core/compile/normalize_charts.py +494 -0
  159. dataface/core/compile/normalize_layout.py +1000 -0
  160. dataface/core/compile/normalize_queries.py +297 -0
  161. dataface/core/compile/normalize_variables.py +489 -0
  162. dataface/core/compile/normalizer.py +543 -0
  163. dataface/core/compile/palette.py +1100 -0
  164. dataface/core/compile/parameterized.py +658 -0
  165. dataface/core/compile/parser.py +228 -0
  166. dataface/core/compile/schema.py +20 -0
  167. dataface/core/compile/schema_renderers/__init__.py +0 -0
  168. dataface/core/compile/schema_renderers/json_schema.py +163 -0
  169. dataface/core/compile/schema_renderers/prompt.py +152 -0
  170. dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
  171. dataface/core/compile/sizing.py +2126 -0
  172. dataface/core/compile/sources.py +518 -0
  173. dataface/core/compile/sql_authoring_lint.py +56 -0
  174. dataface/core/compile/style_cascade.py +471 -0
  175. dataface/core/compile/typography.py +299 -0
  176. dataface/core/compile/validator.py +301 -0
  177. dataface/core/compile/variables.py +53 -0
  178. dataface/core/compile/vega_config.py +98 -0
  179. dataface/core/compile/vega_lite/__init__.py +6 -0
  180. dataface/core/compile/vega_lite/validation.py +95 -0
  181. dataface/core/compile/yaml_error_formatter.py +838 -0
  182. dataface/core/connections.py +38 -0
  183. dataface/core/dashboard.py +358 -0
  184. dataface/core/defaults/default_config.yml +101 -0
  185. dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
  186. dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
  187. dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
  188. dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
  189. dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
  190. dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
  191. dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
  192. dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
  193. dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
  194. dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
  195. dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
  196. dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
  197. dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
  198. dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
  199. dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
  200. dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
  201. dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
  202. dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
  203. dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
  204. dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
  205. dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
  206. dataface/core/defaults/palettes/sequential/amber.yml +22 -0
  207. dataface/core/defaults/palettes/sequential/blue.yml +22 -0
  208. dataface/core/defaults/palettes/sequential/brown.yml +22 -0
  209. dataface/core/defaults/palettes/sequential/gray.yml +22 -0
  210. dataface/core/defaults/palettes/sequential/green.yml +22 -0
  211. dataface/core/defaults/palettes/sequential/purple.yml +22 -0
  212. dataface/core/defaults/palettes/sequential/rust.yml +22 -0
  213. dataface/core/defaults/palettes/sequential/teal.yml +22 -0
  214. dataface/core/defaults/palettes/tone/negative.yml +32 -0
  215. dataface/core/defaults/palettes/tone/positive.yml +22 -0
  216. dataface/core/defaults/palettes/tone/warning.yml +22 -0
  217. dataface/core/defaults/themes/_base.yaml +786 -0
  218. dataface/core/defaults/themes/bi.yaml +16 -0
  219. dataface/core/defaults/themes/carbong100.yaml +41 -0
  220. dataface/core/defaults/themes/cream.yaml +122 -0
  221. dataface/core/defaults/themes/dark.yaml +40 -0
  222. dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
  223. dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
  224. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
  225. dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
  226. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
  227. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
  228. dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
  229. dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
  230. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
  231. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
  232. dataface/core/defaults/themes/editorial.yaml +147 -0
  233. dataface/core/defaults/themes/light.yaml +30 -0
  234. dataface/core/defaults/themes/looker.yaml +17 -0
  235. dataface/core/defaults/themes/stark.yaml +134 -0
  236. dataface/core/errors/__init__.py +67 -0
  237. dataface/core/errors/codes_compile.py +56 -0
  238. dataface/core/errors/codes_execute.py +177 -0
  239. dataface/core/errors/codes_render.py +106 -0
  240. dataface/core/errors/codes_unknown.py +15 -0
  241. dataface/core/errors/hints.py +74 -0
  242. dataface/core/errors/registry.py +42 -0
  243. dataface/core/errors/structured.py +92 -0
  244. dataface/core/execute/__init__.py +91 -0
  245. dataface/core/execute/adapters/__init__.py +49 -0
  246. dataface/core/execute/adapters/adapter_registry.py +400 -0
  247. dataface/core/execute/adapters/base.py +245 -0
  248. dataface/core/execute/adapters/csv_adapter.py +239 -0
  249. dataface/core/execute/adapters/dbt_adapter.py +283 -0
  250. dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
  251. dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
  252. dataface/core/execute/adapters/dbt_utils.py +150 -0
  253. dataface/core/execute/adapters/http_adapter.py +224 -0
  254. dataface/core/execute/adapters/metricflow_adapter.py +94 -0
  255. dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
  256. dataface/core/execute/adapters/sql_adapter.py +710 -0
  257. dataface/core/execute/adapters/values_adapter.py +58 -0
  258. dataface/core/execute/batch.py +744 -0
  259. dataface/core/execute/cache_backend.py +135 -0
  260. dataface/core/execute/cache_keys.py +66 -0
  261. dataface/core/execute/dbt_jinja.py +21 -0
  262. dataface/core/execute/dialects/__init__.py +121 -0
  263. dataface/core/execute/dialects/athena.py +75 -0
  264. dataface/core/execute/dialects/base.py +302 -0
  265. dataface/core/execute/dialects/bigquery.py +38 -0
  266. dataface/core/execute/dialects/databricks.py +68 -0
  267. dataface/core/execute/dialects/duckdb.py +35 -0
  268. dataface/core/execute/dialects/mysql.py +68 -0
  269. dataface/core/execute/dialects/postgres.py +39 -0
  270. dataface/core/execute/dialects/redshift.py +12 -0
  271. dataface/core/execute/dialects/snowflake.py +51 -0
  272. dataface/core/execute/dialects/sqlserver.py +92 -0
  273. dataface/core/execute/duckdb_cache.py +712 -0
  274. dataface/core/execute/duckdb_config.py +26 -0
  275. dataface/core/execute/errors.py +213 -0
  276. dataface/core/execute/executor.py +1249 -0
  277. dataface/core/execute/parallel.py +162 -0
  278. dataface/core/execute/setup_sql.py +58 -0
  279. dataface/core/execute/source_registry.py +72 -0
  280. dataface/core/execute/source_resolver.py +255 -0
  281. dataface/core/execute/sql_guard.py +387 -0
  282. dataface/core/execute/sql_literals.py +199 -0
  283. dataface/core/fonts.py +52 -0
  284. dataface/core/inspect/__init__.py +32 -0
  285. dataface/core/inspect/cache_factory.py +98 -0
  286. dataface/core/inspect/db_types.py +162 -0
  287. dataface/core/inspect/dbt_schema.py +96 -0
  288. dataface/core/inspect/defaults.yml +37 -0
  289. dataface/core/inspect/fanout_risk.py +109 -0
  290. dataface/core/inspect/manifest_utils.py +77 -0
  291. dataface/core/inspect/partials/categorical.yml +40 -0
  292. dataface/core/inspect/partials/date.yml +40 -0
  293. dataface/core/inspect/partials/numeric.yml +55 -0
  294. dataface/core/inspect/partition_types.py +38 -0
  295. dataface/core/inspect/query_validator.py +975 -0
  296. dataface/core/inspect/renderer.py +354 -0
  297. dataface/core/inspect/resolver.py +808 -0
  298. dataface/core/inspect/search.py +461 -0
  299. dataface/core/inspect/sources/__init__.py +32 -0
  300. dataface/core/inspect/sources/dbt.py +738 -0
  301. dataface/core/inspect/sources/duckdb_utils.py +66 -0
  302. dataface/core/inspect/templates/__init__.py +1 -0
  303. dataface/core/inspect/templates/categorical_column.yml +196 -0
  304. dataface/core/inspect/templates/charts.yml +109 -0
  305. dataface/core/inspect/templates/date_column.yml +248 -0
  306. dataface/core/inspect/templates/model.yml +138 -0
  307. dataface/core/inspect/templates/numeric_column.yml +261 -0
  308. dataface/core/inspect/templates/quality.yml +80 -0
  309. dataface/core/inspect/templates/string_column.yml +263 -0
  310. dataface/core/project_roots.py +165 -0
  311. dataface/core/render/__init__.py +87 -0
  312. dataface/core/render/board_links.py +176 -0
  313. dataface/core/render/chart/__init__.py +27 -0
  314. dataface/core/render/chart/arc_attached_table.py +251 -0
  315. dataface/core/render/chart/artifacts.py +16 -0
  316. dataface/core/render/chart/callout.py +225 -0
  317. dataface/core/render/chart/decisions.py +358 -0
  318. dataface/core/render/chart/geo.py +700 -0
  319. dataface/core/render/chart/kpi.py +916 -0
  320. dataface/core/render/chart/labels.py +76 -0
  321. dataface/core/render/chart/pipeline.py +818 -0
  322. dataface/core/render/chart/presentation.py +36 -0
  323. dataface/core/render/chart/profile.py +3438 -0
  324. dataface/core/render/chart/render_single.py +347 -0
  325. dataface/core/render/chart/renderers.py +193 -0
  326. dataface/core/render/chart/rendering.py +565 -0
  327. dataface/core/render/chart/serialization.py +90 -0
  328. dataface/core/render/chart/spark.py +496 -0
  329. dataface/core/render/chart/spark_bar.py +370 -0
  330. dataface/core/render/chart/spec_builders.py +154 -0
  331. dataface/core/render/chart/standard_renderer.py +2645 -0
  332. dataface/core/render/chart/table.py +2957 -0
  333. dataface/core/render/chart/table_support.py +1452 -0
  334. dataface/core/render/chart/tick_values.py +66 -0
  335. dataface/core/render/chart/time_unit_detect.py +809 -0
  336. dataface/core/render/chart/title_overflow.py +157 -0
  337. dataface/core/render/chart/type_inference.py +122 -0
  338. dataface/core/render/chart/validation.py +99 -0
  339. dataface/core/render/chart/vega_lite.py +125 -0
  340. dataface/core/render/chart/vega_lite_types.py +268 -0
  341. dataface/core/render/chart/vl_field_maps.py +346 -0
  342. dataface/core/render/chart_interactivity.py +24 -0
  343. dataface/core/render/control_registry.py +287 -0
  344. dataface/core/render/converters/__init__.py +24 -0
  345. dataface/core/render/converters/chart.py +276 -0
  346. dataface/core/render/converters/html.py +98 -0
  347. dataface/core/render/converters/pdf.py +40 -0
  348. dataface/core/render/converters/png.py +41 -0
  349. dataface/core/render/errors.py +144 -0
  350. dataface/core/render/face_api.py +160 -0
  351. dataface/core/render/faces.py +1194 -0
  352. dataface/core/render/font_measurement.py +48 -0
  353. dataface/core/render/font_support.py +197 -0
  354. dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
  355. dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
  356. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
  357. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
  358. dataface/core/render/fonts/InterVariable.ttf +0 -0
  359. dataface/core/render/fonts/InterVariable.woff2 +0 -0
  360. dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
  361. dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
  362. dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
  363. dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
  364. dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
  365. dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
  366. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
  367. dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
  368. dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
  369. dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
  370. dataface/core/render/fonts/_emoji_font_face.css +43 -0
  371. dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
  372. dataface/core/render/format_utils.py +329 -0
  373. dataface/core/render/geo_defaults.yml +28 -0
  374. dataface/core/render/json_format.py +146 -0
  375. dataface/core/render/layout_sizing.py +865 -0
  376. dataface/core/render/layouts.py +541 -0
  377. dataface/core/render/markdown_defaults.yml +16 -0
  378. dataface/core/render/missing_vars_prompt.py +79 -0
  379. dataface/core/render/placeholder.py +389 -0
  380. dataface/core/render/render_result.py +14 -0
  381. dataface/core/render/renderer.py +467 -0
  382. dataface/core/render/script_embedding.py +16 -0
  383. dataface/core/render/svg_utils.py +212 -0
  384. dataface/core/render/template_loader.py +69 -0
  385. dataface/core/render/templates/controls/_styles.css +606 -0
  386. dataface/core/render/templates/controls/checkbox.html +16 -0
  387. dataface/core/render/templates/controls/date.html +16 -0
  388. dataface/core/render/templates/controls/number.html +19 -0
  389. dataface/core/render/templates/controls/readonly.html +9 -0
  390. dataface/core/render/templates/controls/select.html +21 -0
  391. dataface/core/render/templates/controls/slider.html +22 -0
  392. dataface/core/render/templates/controls/text.html +16 -0
  393. dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
  394. dataface/core/render/templates/scripts/variables.js +976 -0
  395. dataface/core/render/templates/svg/grid_pattern.svg +3 -0
  396. dataface/core/render/templates/svg/styles.css +51 -0
  397. dataface/core/render/terminal.py +311 -0
  398. dataface/core/render/terminal_charts.py +563 -0
  399. dataface/core/render/terminal_defaults.yml +2 -0
  400. dataface/core/render/terminal_layouts.py +299 -0
  401. dataface/core/render/terminal_text.py +31 -0
  402. dataface/core/render/text/__init__.py +1 -0
  403. dataface/core/render/text/case.py +113 -0
  404. dataface/core/render/text_format.py +129 -0
  405. dataface/core/render/utils.py +106 -0
  406. dataface/core/render/variable_controls.py +946 -0
  407. dataface/core/render/variable_input_refinement.py +140 -0
  408. dataface/core/render/warnings/__init__.py +15 -0
  409. dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
  410. dataface/core/render/warnings/base.py +44 -0
  411. dataface/core/render/warnings/fanout_risk.py +15 -0
  412. dataface/core/render/warnings/from_query_diagnostic.py +56 -0
  413. dataface/core/render/warnings/missing_join_predicate.py +13 -0
  414. dataface/core/render/warnings/query_parse_error.py +14 -0
  415. dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
  416. dataface/core/render/warnings/reaggregation.py +14 -0
  417. dataface/core/render/warnings/registry.py +45 -0
  418. dataface/core/render/warnings/suppression.py +46 -0
  419. dataface/core/render/warnings/temporal_single_point.py +63 -0
  420. dataface/core/render/warnings/unreferenced_chart.py +15 -0
  421. dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
  422. dataface/core/render/yaml_format.py +167 -0
  423. dataface/core/resolve_face.py +195 -0
  424. dataface/core/schema/__init__.py +0 -0
  425. dataface/core/schema/guidance.py +151 -0
  426. dataface/core/scoped_paths.py +59 -0
  427. dataface/core/serve/__init__.py +14 -0
  428. dataface/core/serve/bootstrap.py +39 -0
  429. dataface/core/serve/embedded.py +57 -0
  430. dataface/core/serve/port.py +129 -0
  431. dataface/core/serve/server.py +938 -0
  432. dataface/core/serve/templates/__init__.py +0 -0
  433. dataface/core/serve/templates/directory.yml +6 -0
  434. dataface/core/serve/templates/error.html.j2 +217 -0
  435. dataface/core/utils.py +121 -0
  436. dataface/core/validate.py +64 -0
  437. dataface/integrations/__init__.py +0 -0
  438. dataface/integrations/highlighting.py +351 -0
  439. dataface/integrations/markdown.py +537 -0
  440. dataface/py.typed +0 -0
  441. dataface-0.1.2.dist-info/METADATA +375 -0
  442. dataface-0.1.2.dist-info/RECORD +455 -0
  443. dataface-0.1.2.dist-info/WHEEL +4 -0
  444. dataface-0.1.2.dist-info/entry_points.txt +2 -0
  445. dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
  446. mdsvg/__init__.py +168 -0
  447. mdsvg/fonts.py +656 -0
  448. mdsvg/images.py +299 -0
  449. mdsvg/parser.py +629 -0
  450. mdsvg/playground.py +284 -0
  451. mdsvg/py.typed +2 -0
  452. mdsvg/renderer.py +1623 -0
  453. mdsvg/style.py +355 -0
  454. mdsvg/types.py +200 -0
  455. mdsvg/utils.py +86 -0
@@ -0,0 +1,1287 @@
1
+ """Vega-Lite attachment for the chart.data_table primitive.
2
+
3
+ Post-pass on a chart-body Vega-Lite spec. Given a validated ChartDataTable
4
+ and the chart's x-encoding, emit:
5
+ - An optional strip-top divider rule (when divider.width > 0).
6
+ - One text layer per row — source rows use a format() calculate transform;
7
+ aggregate rows add a Vega-Lite aggregate transform grouped by x.
8
+ - N-1 inter-row rule layers when row.rule.width > 0.
9
+ - One label text layer per row that carries label: — emitted at the
10
+ y-axis tick label's x-anchor (right gutter for right-oriented axes,
11
+ left gutter for left-oriented axes), derived from the resolved axis_y.orient.
12
+
13
+ No new primitives: every output layer is standard Vega-Lite (mark: text,
14
+ mark: rule).
15
+
16
+ Y-positioning is pixel-literal (`{"y": {"value": <px>}}`), computed from
17
+ the spec's explicit height plus per-row offsets. Vega-Lite treats
18
+ `{"y": {"expr": "..."}}` as a SCALED data value (not a pixel literal),
19
+ so the previous expr-based approach collapsed every row to one pixel
20
+ position via the parent's quantitative y-scale. Spec.height must be set
21
+ when data_table is non-None — auto-sized specs have no anchor.
22
+
23
+ The caller owns the padding allocation. Compute the required height with
24
+ ``data_table_strip_height`` and add it to the appropriate padding side
25
+ (``top`` for position=top, ``bottom`` for position=bottom) via
26
+ ``bump_padding_top`` / ``bump_padding_bottom``. The renderer's downstream
27
+ finalize step respects the external padding kwarg when supplied.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import math
33
+ from typing import Any, Literal
34
+
35
+ from dataface.core.compile.models.chart.authored import (
36
+ CHART_DATA_TABLE_MAX_X_TICKS,
37
+ ChartDataTable,
38
+ ChartDataTableAggregate,
39
+ ChartDataTableAggregateOp,
40
+ ChartDataTablePerSeries,
41
+ )
42
+ from dataface.core.compile.models.style.compiled import DataTableStyle
43
+ from dataface.core.compile.models.style.merged import (
44
+ MergedChartsStyle,
45
+ deep_merge,
46
+ )
47
+ from dataface.core.compile.palette import resolve_dark_companion_stops
48
+ from dataface.core.render.format_utils import resolve_format
49
+
50
+ # Map authoring-surface aggregate names to Vega-Lite aggregate ops.
51
+ # G4 (spec §2.4) keeps authoring exact; the compiler is free to translate.
52
+ # Keys are the ChartDataTableAggregateOp Literal values; the test guard
53
+ # test_agg_op_map_keys_are_all_authoring_surface_ops enforces coverage.
54
+ _AGG_OP_TO_VL: dict[ChartDataTableAggregateOp, str] = {
55
+ "sum": "sum",
56
+ "avg": "mean",
57
+ "min": "min",
58
+ "max": "max",
59
+ "median": "median",
60
+ "count": "count",
61
+ "count_distinct": "distinct",
62
+ }
63
+
64
+ # Row geometry. These are the compiler's constants — they live here rather
65
+ # than on the style model because they are emitter mechanics, not user-facing
66
+ # theme tokens. If a theme wants denser rows, padding.vertical lets it.
67
+ _ROW_HEIGHT_BASE = 14.0 # per-row pixel height before padding
68
+ _DIVIDER_GAP = 4.0 # vertical gap between divider rule and first row
69
+ # Row labels share the y-axis tick column (~50–70 px for typical numeric
70
+ # formats). 80 px accommodates labels up to ~12 chars at 11 px Inter without
71
+ # the label reaching visibly into the legend zone or over-shrinking the plot.
72
+ _LABEL_STUB_LIMIT_PX = 80.0
73
+
74
+
75
+ def _row_height(style: DataTableStyle) -> float:
76
+ padding = style.row.padding
77
+ return _ROW_HEIGHT_BASE + padding.vertical * 2.0
78
+
79
+
80
+ def _strip_height(style: DataTableStyle, n_rows: int) -> float:
81
+ """Total pixel height for the attached strip (position-agnostic)."""
82
+ row_h = _row_height(style)
83
+ divider = (style.divider.width + _DIVIDER_GAP) if style.divider.width > 0 else 0.0
84
+ inter_row = (
85
+ style.row.rule.width * (n_rows - 1)
86
+ if style.row.rule.width > 0 and n_rows > 1
87
+ else 0.0
88
+ )
89
+ return (
90
+ style.padding_top + divider + row_h * n_rows + inter_row + style.padding_bottom
91
+ )
92
+
93
+
94
+ def data_table_strip_height(
95
+ data_table: ChartDataTable | None,
96
+ style: DataTableStyle,
97
+ charts_style: MergedChartsStyle,
98
+ series_count: int = 0,
99
+ ) -> float:
100
+ """Pixel height to reserve on the appropriate padding side when attaching a strip.
101
+
102
+ For ``position: bottom``: includes the axis-label gap between the plot bottom
103
+ and the strip top; callers pass the result to ``padding.bottom``.
104
+
105
+ For ``position: top``: no axis gap (x-axis is below the plot); callers pass
106
+ the result to ``padding.top``.
107
+
108
+ Returns 0 when no strip is attached.
109
+
110
+ Args:
111
+ data_table: The data_table block, or None for no strip.
112
+ style: Resolved DataTableStyle (must carry ``position``).
113
+ charts_style: Resolved MergedChartsStyle for axis offset calculation.
114
+ series_count: How many series each ``per_series:`` entry expands to.
115
+ All per_series entries on the same chart share one color domain,
116
+ so one int suffices for the strip-height accounting. Required
117
+ (>0) when any entry is ``ChartDataTablePerSeries`` — leaving it
118
+ at 0 with a per_series entry present silently under-sizes the
119
+ strip and the expanded rows render below the reserved padding.
120
+ ValueError is raised in that case rather than guessing 1.
121
+ """
122
+ if data_table is None or not data_table.entries:
123
+ return 0.0
124
+ n_rows = 0
125
+ for entry in data_table.entries:
126
+ if isinstance(entry, ChartDataTablePerSeries):
127
+ if entry.by_measure:
128
+ n_rows += 1
129
+ else:
130
+ if series_count <= 0:
131
+ raise ValueError(
132
+ "data_table_strip_height needs series_count > 0 when any "
133
+ "entry is `per_series:` — pass the number of color-domain "
134
+ "series the chart expands to. Defaulting to 1 silently "
135
+ "under-sizes the strip and the expanded rows render below "
136
+ "the reserved padding."
137
+ )
138
+ n_rows += series_count
139
+ else:
140
+ n_rows += 1
141
+ strip_h = _strip_height(style, n_rows)
142
+ if style.position == "bottom":
143
+ return _axis_offset(charts_style, style) + strip_h
144
+ # top: x-axis is below the plot — no axis gap above the strip.
145
+ return strip_h
146
+
147
+
148
+ def _axis_offset(charts_style: MergedChartsStyle, style: DataTableStyle) -> float:
149
+ """Pixel offset from plot bottom edge to the bottom of the x-axis block.
150
+
151
+ Sums whichever axis components are actually visible: a chart with the
152
+ title shown but labels suppressed still needs the title's height to
153
+ sit above the strip, otherwise the strip's first row collides with it.
154
+
155
+ label_max_lines × label height is reserved so yearly boundary ticks that
156
+ emit a two-element array (e.g. ['Jan', '2024']) do not overlap the strip
157
+ top edge. style.label_max_lines = 2 is the universal default; themes with
158
+ a year-only cadence may set 1.
159
+ """
160
+ axis = charts_style.axis_x
161
+ label_h = (
162
+ axis.label.padding + axis.label.font.size * style.label_max_lines
163
+ if axis.label.font.size > 0
164
+ else 0.0
165
+ )
166
+ title_h = (
167
+ axis.title.padding + axis.title.font.size if axis.title.font.size > 0 else 0.0
168
+ )
169
+ return label_h + title_h
170
+
171
+
172
+ def _row_y_pixel(
173
+ index: int,
174
+ style: DataTableStyle,
175
+ charts_style: MergedChartsStyle,
176
+ spec_height: float,
177
+ ) -> float:
178
+ """Pixel y of the centroid of the ith row in the attached strip.
179
+
180
+ For ``position: bottom``: y > spec_height (strip below the plot). Row 0 is
181
+ closest to the plot (first below the divider); row index increases downward.
182
+
183
+ For ``position: top``: y < 0 (strip above the plot, in padding.top zone).
184
+ Row 0 is closest to the plot top (y nearest 0); row index increases upward
185
+ (y becomes more negative). No axis-offset is needed since the x-axis is below.
186
+ """
187
+ divider_off = style.divider.width + _DIVIDER_GAP if style.divider.width > 0 else 0.0
188
+ row_h = _row_height(style)
189
+ inter_row = (
190
+ style.row.rule.width * index if style.row.rule.width > 0 and index > 0 else 0.0
191
+ )
192
+ if style.position == "bottom":
193
+ axis_off = _axis_offset(charts_style, style)
194
+ offset = (
195
+ style.padding_top
196
+ + axis_off
197
+ + divider_off
198
+ + index * row_h
199
+ + inter_row
200
+ + row_h / 2.0
201
+ )
202
+ return spec_height + offset
203
+ # top: measure from y=0 (plot top) going upward (negative y).
204
+ # Row 0 is closest to the plot; row N is furthest above.
205
+ # Layout (from y=0 upward): padding_bottom, then rows (0…N), then padding_top.
206
+ # Divider sits just above y=0, between padding_bottom and rows.
207
+ dist_from_plot_top = (
208
+ style.padding_bottom + divider_off + index * row_h + inter_row + row_h / 2.0
209
+ )
210
+ return -dist_from_plot_top
211
+
212
+
213
+ def _divider_y_pixel(
214
+ style: DataTableStyle,
215
+ charts_style: MergedChartsStyle,
216
+ spec_height: float,
217
+ ) -> float:
218
+ """Pixel y of the divider rule.
219
+
220
+ For ``position: bottom``: divider sits between the axis and the strip rows
221
+ (just below axis labels), at spec_height + padding_top + axis_offset.
222
+
223
+ For ``position: top``: divider sits between the strip rows and the plot top
224
+ (just above y=0), at -(padding_bottom).
225
+ """
226
+ if style.position == "bottom":
227
+ axis_off = _axis_offset(charts_style, style)
228
+ return spec_height + style.padding_top + axis_off
229
+ # top: divider is the boundary between strip and plot.
230
+ return -style.padding_bottom
231
+
232
+
233
+ def _inter_row_rule_y_pixel(
234
+ index: int,
235
+ style: DataTableStyle,
236
+ charts_style: MergedChartsStyle,
237
+ spec_height: float,
238
+ ) -> float:
239
+ """Pixel y of the inter-row rule between row `index` and row `index + 1`.
240
+
241
+ Centroid sits in the middle of the inter-row gap so the rule's stroke
242
+ splits evenly between the two adjacent rows; without the half-stroke
243
+ offset the rule biases visually toward the row above.
244
+
245
+ For ``position: bottom``: row y values increase downward, so the rule
246
+ between row 0 and row 1 is *below* row 0's centroid → add half-row +
247
+ half-stroke.
248
+
249
+ For ``position: top``: row y values are negative (above the plot) and
250
+ become *more negative* as index increases. The rule between row 0 and
251
+ row 1 must be *more negative* than row 0's centroid → subtract.
252
+ """
253
+ row_y = _row_y_pixel(index, style, charts_style, spec_height)
254
+ half_gap = _row_height(style) / 2.0 + style.row.rule.width / 2.0
255
+ if style.position == "bottom":
256
+ return row_y + half_gap
257
+ # top: "between rows" is more negative (further above plot)
258
+ return row_y - half_gap
259
+
260
+
261
+ def _vl_format_calc(
262
+ source: str,
263
+ format_spec: str | None,
264
+ as_name: str,
265
+ formats: dict[str, str] | None = None,
266
+ ) -> dict[str, Any]:
267
+ """Build a Vega-Lite calculate transform that formats a source column."""
268
+ if format_spec is None:
269
+ # Pass-through: text layer binds directly to the source field.
270
+ return {"calculate": f"datum.{source}", "as": as_name}
271
+ # Resolve alias before emitting — format_spec may be a theme alias (e.g.
272
+ # "currency") rather than a raw d3 spec (e.g. "$,.2f").
273
+ resolved = resolve_format(format_spec, formats)
274
+ return {"calculate": f"format(datum.{source}, '{resolved}')", "as": as_name}
275
+
276
+
277
+ def _wrap_base_as_layer(spec: dict[str, Any]) -> dict[str, Any]:
278
+ """Lift a single-mark spec into {layer: [base, ...]} form if needed."""
279
+ if "layer" in spec:
280
+ return dict(spec)
281
+ base_layer: dict[str, Any] = {}
282
+ for key in ("mark", "encoding", "transform"):
283
+ if key in spec:
284
+ base_layer[key] = spec[key]
285
+ new_spec = {k: v for k, v in spec.items() if k not in ("mark", "encoding")}
286
+ # `transform` stays on the base layer; the attached layers carry their own.
287
+ if "transform" in new_spec:
288
+ del new_spec["transform"]
289
+ # Promote tooltip to spec-level so strip layers inherit it (PR #1877).
290
+ # Strip text marks have only x + a calc text field; without an inherited
291
+ # tooltip, hover shows the raw channel field (e.g. ``revenue: 100.5``)
292
+ # instead of titled-and-formatted output.
293
+ #
294
+ # Two paths:
295
+ # - If the base encoding carries an explicit tooltip array (legacy callers,
296
+ # unit tests that hand-feed one): promote it as-is.
297
+ # - Otherwise: synthesize from the base's x/y channels — the bar layer's
298
+ # channels carry title + format after this PR, so the synthesized array
299
+ # is the same content the legacy tooltip array used to carry.
300
+ #
301
+ # Dict comprehension (not .pop) — base_layer["encoding"] is a shared
302
+ # reference to spec["encoding"] and mutation would corrupt the input.
303
+ base_encoding = base_layer.get("encoding")
304
+ if isinstance(base_encoding, dict):
305
+ existing_tooltip = base_encoding.get("tooltip")
306
+ if existing_tooltip is not None:
307
+ base_layer["encoding"] = {
308
+ k: v for k, v in base_encoding.items() if k != "tooltip"
309
+ }
310
+ new_spec["encoding"] = {"tooltip": existing_tooltip}
311
+ else:
312
+ spec_tooltip = [
313
+ {k: enc[k] for k in ("field", "type", "title", "format") if k in enc}
314
+ for ch in ("x", "y")
315
+ if (enc := base_encoding.get(ch, {})) and enc.get("field")
316
+ ]
317
+ if spec_tooltip:
318
+ new_spec["encoding"] = {"tooltip": spec_tooltip}
319
+ new_spec["layer"] = [base_layer]
320
+ return new_spec
321
+
322
+
323
+ def _shared_x_encoding(parent_x_enc: dict[str, Any]) -> dict[str, Any]:
324
+ """Shared x-encoding for all attached layers.
325
+
326
+ Copies field, type, and (when present) timeUnit from the parent chart's
327
+ x-encoding so strip layers share the same scale type. Mismatched types
328
+ (e.g. parent temporal vs strip ordinal) cause a vl-convert null-deref.
329
+
330
+ bandPosition rules:
331
+ - temporal + timeUnit → 1.0 (right-edge anchor; pairs with align:right
332
+ so text's right edge lines up with the bar's
333
+ right wall — no dx centering for these).
334
+ - ordinal / nominal → 0.5 (explicit band-centre anchor; pairs with a
335
+ per-entry dx from _compute_data_table_entry_dx
336
+ to implement table-parity column centering:
337
+ right edge at band_centre + max_w/2).
338
+ - quantitative / plain temporal → omitted (continuous scale, no bands).
339
+
340
+ No axis: null — setting axis to None crashes vl-convert with a TypeError
341
+ in parseAxesAndHeaders. Omitting the axis key entirely is correct; Vega-Lite
342
+ infers axis rendering from context and the text mark does not need axis ticks.
343
+ """
344
+ enc: dict[str, Any] = {"field": parent_x_enc["field"], "type": parent_x_enc["type"]}
345
+ if "timeUnit" in parent_x_enc:
346
+ enc["timeUnit"] = parent_x_enc["timeUnit"]
347
+ enc["bandPosition"] = 1.0
348
+ elif parent_x_enc["type"] in ("ordinal", "nominal"):
349
+ enc["bandPosition"] = 0.5
350
+ return enc
351
+
352
+
353
+ def _text_mark_props(style: DataTableStyle, dx: float | None = None) -> dict[str, Any]:
354
+ """Common mark properties for row-text layers.
355
+
356
+ dx, when set, offsets the text mark so the number lane's centre aligns
357
+ with the band midpoint — the same centre-on-midpoint invariant as
358
+ _compute_lane_positions in table.py. Caller passes
359
+ dx = max_formatted_width / 2 (always positive).
360
+
361
+ Sign convention (mirrors table lane geometry):
362
+ align=right → +dx (right edge at band_center + max_w/2 → column centred)
363
+ align=left → -dx (left edge at band_center - max_w/2 → column centred)
364
+ """
365
+ align_map = {"left": "left", "right": "right", "decimal": "right"}
366
+ mark: dict[str, Any] = {
367
+ "type": "text",
368
+ "align": align_map[style.number_align],
369
+ "baseline": "middle",
370
+ }
371
+ if dx is not None:
372
+ # Left-aligned text anchors on its left edge; negate so the column
373
+ # centre sits at the band midpoint rather than shifting right.
374
+ mark["dx"] = -dx if style.number_align == "left" else dx
375
+ font = style.font
376
+ if font.family is not None:
377
+ mark["font"] = font.family
378
+ if font.size is not None:
379
+ mark["fontSize"] = font.size
380
+ if font.weight is not None:
381
+ mark["fontWeight"] = font.weight
382
+ if font.color is not None:
383
+ mark["fill"] = font.color
384
+ return mark
385
+
386
+
387
+ def _row_text_layer(
388
+ index: int,
389
+ entry: Any,
390
+ parent_x_enc: dict[str, Any],
391
+ style: DataTableStyle,
392
+ charts_style: MergedChartsStyle,
393
+ spec_height: float,
394
+ sampling_step: int = 1,
395
+ dx: float | None = None,
396
+ label_period_filter_expr: str | None = None,
397
+ formats: dict[str, str] | None = None,
398
+ ) -> dict[str, Any]:
399
+ """Emit a text layer for one data_table row (source or aggregate).
400
+
401
+ When sampling_step > 1, a Vega-Lite window+filter chain is inserted so
402
+ only every Nth x position renders a cell. Transform ordering matters:
403
+ - For aggregate entries: [aggregate, (period_filter), window, filter, format_calc]
404
+ The aggregate collapses multi-row-per-x data first; period filter then thins
405
+ to label-period openers; sampling then thins further if needed.
406
+ - For source entries (1 row per x, guaranteed by G1): [(period_filter), window, filter, format_calc]
407
+
408
+ When label_period_filter_expr is set, a filter transform is inserted to
409
+ keep only x-values that open a new label period (e.g. quarterly openers on
410
+ a monthly-band axis). This prevents the data_table strip from rendering one
411
+ cell per band when the axis labels are at a coarser cadence.
412
+ """
413
+ is_agg = isinstance(entry, ChartDataTableAggregate)
414
+ x_field = parent_x_enc["field"]
415
+ # Internal field name for the formatted cell value.
416
+ cell_name = f"__data_table_{index}"
417
+
418
+ transforms: list[dict[str, Any]] = []
419
+ if is_agg:
420
+ vl_op = _AGG_OP_TO_VL[entry.aggregate]
421
+ agg_name = f"{cell_name}_val"
422
+ transforms.append(
423
+ {
424
+ "aggregate": [{"op": vl_op, "field": entry.source, "as": agg_name}],
425
+ "groupby": [x_field],
426
+ }
427
+ )
428
+ # Period filter after aggregate: one row per x, filter to label-period openers.
429
+ if label_period_filter_expr is not None:
430
+ transforms.append({"filter": label_period_filter_expr})
431
+ # Sampling after period filter: thin further if still over the cap.
432
+ if sampling_step > 1:
433
+ transforms.extend(_sampling_transforms(x_field, sampling_step))
434
+ if entry.format is not None:
435
+ transforms.append(
436
+ _vl_format_calc(agg_name, entry.format, cell_name, formats)
437
+ )
438
+ else:
439
+ # pass raw aggregate value through as text
440
+ cell_name = agg_name
441
+ else:
442
+ # Source entry: G1 guarantees 1 row per x.
443
+ # Period filter before sampling: thin to label-period openers first.
444
+ if label_period_filter_expr is not None:
445
+ transforms.append({"filter": label_period_filter_expr})
446
+ if sampling_step > 1:
447
+ transforms.extend(_sampling_transforms(x_field, sampling_step))
448
+ if entry.format is not None:
449
+ transforms.append(
450
+ _vl_format_calc(entry.source, entry.format, cell_name, formats)
451
+ )
452
+ else:
453
+ cell_name = entry.source
454
+
455
+ y_pixel = _row_y_pixel(index, style, charts_style, spec_height)
456
+ encoding: dict[str, Any] = {
457
+ "x": _shared_x_encoding(parent_x_enc),
458
+ "y": {"value": y_pixel},
459
+ "text": {"field": cell_name},
460
+ }
461
+ layer: dict[str, Any] = {
462
+ "mark": _text_mark_props(style, dx=dx),
463
+ "encoding": encoding,
464
+ }
465
+ if transforms:
466
+ layer["transform"] = transforms
467
+ return layer
468
+
469
+
470
+ def _per_series_row_layers(
471
+ row_start_index: int,
472
+ entry: ChartDataTablePerSeries,
473
+ *,
474
+ parent_x_enc: dict[str, Any],
475
+ color_field: str | None,
476
+ series_order: list[str],
477
+ series_palette: list[str] | None,
478
+ style: DataTableStyle,
479
+ charts_style: MergedChartsStyle,
480
+ spec_height: float,
481
+ spec_width: float | None,
482
+ sampling_step: int = 1,
483
+ dx: float | None = None,
484
+ label_period_filter_expr: str | None = None,
485
+ axis_y_orient: Literal["left", "right"],
486
+ label_limit: float | None = None,
487
+ formats: dict[str, str] | None = None,
488
+ ) -> list[dict[str, Any]]:
489
+ """Emit one text layer + one label layer per series for a per_series entry.
490
+
491
+ When ``entry.by_measure`` is True, emits a single row reading the named
492
+ measure field directly (no color groupby/filter). Otherwise expands into
493
+ one text layer + one label layer per series name in series_order.
494
+
495
+ For the normal (non-by_measure) path:
496
+ - Each layer has an aggregate transform groupby [x, color_field].
497
+ - A filter transform selects the specific series.
498
+ - The label layer carries the series name with fill = dark companion ink.
499
+
500
+ Args:
501
+ row_start_index: The pixel-row index of the first series row.
502
+ entry: The ChartDataTablePerSeries entry to expand.
503
+ parent_x_enc: The parent chart's x-encoding dict.
504
+ color_field: The column name used for the color: channel. Required for
505
+ normal mode; may be None when ``entry.by_measure`` is True.
506
+ series_order: Ordered series names (stack/scale order).
507
+ series_palette: The effective palette colours for each series in
508
+ series_order. When None, label fill is omitted (no companion resolution).
509
+ style: Resolved DataTableStyle.
510
+ charts_style: Resolved MergedChartsStyle.
511
+ spec_height: The chart spec's pixel height.
512
+ spec_width: The chart spec's pixel width. Required (non-None) when
513
+ ``axis_y_orient == "right"``; ignored for left-cap.
514
+ sampling_step: Sampling step for dense x axes (>1 thins cells).
515
+ dx: Optional band-centering dx for cell text marks.
516
+ axis_y_orient: Resolved y-axis orient — "right" or "left". Determines
517
+ which label emitter fires and the mark's x-anchor and text-anchor.
518
+ label_limit: mark.limit (pixels) applied to every label layer. When None,
519
+ no limit is set. Pass _LABEL_STUB_LIMIT_PX to prevent labels from
520
+ reaching into the legend zone.
521
+
522
+ Returns:
523
+ Flat list of Vega-Lite layer dicts (cell text + label text per series).
524
+
525
+ Raises:
526
+ ValueError when ``axis_y_orient == "right"`` but ``spec_width`` is None.
527
+ """
528
+ if axis_y_orient == "right" and spec_width is None:
529
+ raise ValueError(
530
+ "attach_data_table requires the spec to carry an explicit "
531
+ "width when right-cap labels are present on per_series rows — "
532
+ "pixel-literal label positioning has no anchor otherwise."
533
+ )
534
+
535
+ # by_measure mode: one row reading the named field directly.
536
+ if entry.by_measure:
537
+ row_idx = row_start_index
538
+ bm_cell_name = f"__data_table_{row_idx}"
539
+ bm_transforms: list[dict[str, Any]] = []
540
+ if label_period_filter_expr is not None:
541
+ bm_transforms.append({"filter": label_period_filter_expr})
542
+ if sampling_step > 1:
543
+ bm_transforms.extend(
544
+ _sampling_transforms(parent_x_enc["field"], sampling_step)
545
+ )
546
+ if entry.format is not None:
547
+ bm_transforms.append(
548
+ {
549
+ "calculate": f"format(datum.{entry.per_series}, '{entry.format}')",
550
+ "as": bm_cell_name,
551
+ }
552
+ )
553
+ else:
554
+ bm_cell_name = entry.per_series
555
+ bm_y_pixel = _row_y_pixel(row_idx, style, charts_style, spec_height)
556
+ bm_cell_layer: dict[str, Any] = {
557
+ "mark": _text_mark_props(style, dx=dx),
558
+ "encoding": {
559
+ "x": _shared_x_encoding(parent_x_enc),
560
+ "y": {"value": bm_y_pixel},
561
+ "text": {"field": bm_cell_name},
562
+ },
563
+ }
564
+ if bm_transforms:
565
+ bm_cell_layer["transform"] = bm_transforms
566
+ # Label stub: use entry.label when set, else fall back to the field name.
567
+ bm_label_text = entry.label if entry.label is not None else entry.per_series
568
+ if axis_y_orient == "right":
569
+ assert spec_width is not None # pre-check above guarantees this
570
+ bm_label_layer = _label_stub_right_layer(
571
+ row_idx,
572
+ bm_label_text,
573
+ style=style,
574
+ charts_style=charts_style,
575
+ spec_height=spec_height,
576
+ spec_width=spec_width,
577
+ limit=label_limit,
578
+ )
579
+ else:
580
+ bm_label_layer = _label_stub_left_layer(
581
+ row_idx,
582
+ bm_label_text,
583
+ style=style,
584
+ charts_style=charts_style,
585
+ spec_height=spec_height,
586
+ limit=label_limit,
587
+ )
588
+ return [bm_cell_layer, bm_label_layer]
589
+
590
+ # Normal mode: one cell + label per series in series_order.
591
+ # color_field is guaranteed non-None here: attach_data_table raises ValueError
592
+ # before calling this function for normal-mode entries when color_field is None.
593
+ dark_fills: list[str | None]
594
+ if series_palette is not None:
595
+ dark_fills = list(resolve_dark_companion_stops(series_palette))
596
+ else:
597
+ dark_fills = [None] * len(series_order)
598
+
599
+ x_field = parent_x_enc["field"]
600
+ layers: list[dict[str, Any]] = []
601
+
602
+ for series_idx, series_value in enumerate(series_order):
603
+ row_idx = row_start_index + series_idx
604
+ cell_name = f"__data_table_{row_idx}"
605
+ agg_name = f"{cell_name}_val"
606
+
607
+ transforms: list[dict[str, Any]] = [
608
+ {
609
+ "aggregate": [{"op": "sum", "field": entry.per_series, "as": agg_name}],
610
+ "groupby": [x_field, color_field],
611
+ },
612
+ ]
613
+ # Filter to this series first, THEN apply period filter, THEN sample.
614
+ # Sampling after the aggregate but before the per-series filter would
615
+ # window over the (x, series) cross-product, where ties on x_field have
616
+ # unspecified row-number order — so series A might keep x1/x3/x5
617
+ # while series B keeps x2/x4/x6, with cells at inconsistent
618
+ # x positions across the strip's rows. Sampling AFTER the
619
+ # per-series filter gives each series an independent 1-row-per-x
620
+ # input that thins consistently. Period filter goes between the
621
+ # per-series filter and sampling: it operates on per-series 1-row-per-x
622
+ # data and further thins to label-period openers.
623
+ safe_val = str(series_value).replace("\\", "\\\\").replace("'", "\\'")
624
+ transforms.append({"filter": f"datum['{color_field}'] === '{safe_val}'"})
625
+ if label_period_filter_expr is not None:
626
+ transforms.append({"filter": label_period_filter_expr})
627
+ if sampling_step > 1:
628
+ transforms.extend(_sampling_transforms(x_field, sampling_step))
629
+ if entry.format is not None:
630
+ transforms.append(
631
+ _vl_format_calc(agg_name, entry.format, cell_name, formats)
632
+ )
633
+ else:
634
+ cell_name = agg_name
635
+
636
+ y_pixel = _row_y_pixel(row_idx, style, charts_style, spec_height)
637
+ cell_layer: dict[str, Any] = {
638
+ "mark": _text_mark_props(style, dx=dx),
639
+ "encoding": {
640
+ "x": _shared_x_encoding(parent_x_enc),
641
+ "y": {"value": y_pixel},
642
+ "text": {"field": cell_name},
643
+ },
644
+ "transform": transforms,
645
+ }
646
+ layers.append(cell_layer)
647
+
648
+ # Label layer — series name with companion-resolved fill ink.
649
+ # Delegates to _label_stub_right_layer / _label_stub_left_layer so
650
+ # the orient-derived geometry (x, align, limit) lives in one place.
651
+ dark_fill = dark_fills[series_idx] if series_idx < len(dark_fills) else None
652
+ if axis_y_orient == "right":
653
+ assert spec_width is not None # pre-loop check guarantees this
654
+ label_layer = _label_stub_right_layer(
655
+ row_idx,
656
+ str(series_value),
657
+ style=style,
658
+ charts_style=charts_style,
659
+ spec_height=spec_height,
660
+ spec_width=spec_width,
661
+ limit=label_limit,
662
+ )
663
+ else:
664
+ label_layer = _label_stub_left_layer(
665
+ row_idx,
666
+ str(series_value),
667
+ style=style,
668
+ charts_style=charts_style,
669
+ spec_height=spec_height,
670
+ limit=label_limit,
671
+ )
672
+ if dark_fill is not None:
673
+ label_layer["mark"]["fill"] = dark_fill
674
+ layers.append(label_layer)
675
+
676
+ return layers
677
+
678
+
679
+ def _divider_rule_layer(
680
+ style: DataTableStyle,
681
+ charts_style: MergedChartsStyle,
682
+ spec_height: float,
683
+ ) -> dict[str, Any]:
684
+ """Strip-top divider rule (ADR-006: rule-only, no header text)."""
685
+ mark: dict[str, Any] = {
686
+ "type": "rule",
687
+ "strokeWidth": style.divider.width,
688
+ }
689
+ if style.divider.color is not None:
690
+ mark["stroke"] = style.divider.color
691
+ return {
692
+ "data": {"values": [{}]},
693
+ "mark": mark,
694
+ "encoding": {
695
+ "y": {"value": _divider_y_pixel(style, charts_style, spec_height)}
696
+ },
697
+ }
698
+
699
+
700
+ def _row_rule_layer(
701
+ index: int,
702
+ style: DataTableStyle,
703
+ charts_style: MergedChartsStyle,
704
+ spec_height: float,
705
+ ) -> dict[str, Any]:
706
+ """Inter-row rule between row `index` and row `index + 1` (spec §4.3)."""
707
+ mark: dict[str, Any] = {
708
+ "type": "rule",
709
+ "strokeWidth": style.row.rule.width,
710
+ }
711
+ if style.row.rule.color is not None:
712
+ mark["stroke"] = style.row.rule.color
713
+ return {
714
+ "data": {"values": [{}]},
715
+ "mark": mark,
716
+ "encoding": {
717
+ "y": {
718
+ "value": _inter_row_rule_y_pixel(
719
+ index, style, charts_style, spec_height
720
+ )
721
+ }
722
+ },
723
+ }
724
+
725
+
726
+ def _label_mark_props(style: DataTableStyle, align: str) -> dict[str, Any]:
727
+ """Common mark properties shared by left-stub and right-cap label layers.
728
+
729
+ `align` is derived from the chart's resolved axis_y.orient at the call site:
730
+ right-oriented → "left" (text-anchor start); left-oriented → "right" (text-anchor end).
731
+ """
732
+ mark: dict[str, Any] = {
733
+ "type": "text",
734
+ "align": align,
735
+ "baseline": "middle",
736
+ }
737
+ font = style.label.font
738
+ if font.family is not None:
739
+ mark["font"] = font.family
740
+ if font.size is not None:
741
+ mark["fontSize"] = font.size
742
+ if font.weight is not None:
743
+ mark["fontWeight"] = font.weight
744
+ if font.color is not None:
745
+ mark["fill"] = font.color
746
+ return mark
747
+
748
+
749
+ def _label_stub_left_layer(
750
+ index: int,
751
+ label_text: str,
752
+ style: DataTableStyle,
753
+ charts_style: MergedChartsStyle,
754
+ spec_height: float,
755
+ limit: float | None = None,
756
+ ) -> dict[str, Any]:
757
+ """Left-gutter label layer anchored to the left y-axis tick column.
758
+
759
+ x = -axis_y.label.padding (same column as left-oriented y-axis tick labels,
760
+ which extend leftward from x=0 with text-anchor end / align right).
761
+ mark.limit caps the text width so VL's autosize does not shrink the plot.
762
+ """
763
+ axis_label_padding = charts_style.axis_y.label.padding
764
+ x_pixel = -axis_label_padding
765
+ mark = _label_mark_props(style, align="right")
766
+ if limit is not None:
767
+ mark["limit"] = limit
768
+ return {
769
+ "data": {"values": [{"__label": label_text}]},
770
+ "mark": mark,
771
+ "encoding": {
772
+ "x": {"value": x_pixel},
773
+ "y": {"value": _row_y_pixel(index, style, charts_style, spec_height)},
774
+ "text": {"field": "__label"},
775
+ # Opt out of inherited color encoding. Without this, VL sees own data
776
+ # that lacks the series field, adds null to the categorical domain, and
777
+ # null sorts first — consuming palette[0] and shifting every series mark
778
+ # one slot up (the dark-companion off-by-one).
779
+ "color": None,
780
+ },
781
+ }
782
+
783
+
784
+ def _label_stub_right_layer(
785
+ index: int,
786
+ label_text: str,
787
+ style: DataTableStyle,
788
+ charts_style: MergedChartsStyle,
789
+ spec_height: float,
790
+ spec_width: float,
791
+ limit: float | None = None,
792
+ ) -> dict[str, Any]:
793
+ """Right-cap label layer anchored to the right y-axis tick column.
794
+
795
+ x = spec_width + axis_y.label.padding (same column as right-oriented y-axis
796
+ tick labels, which extend rightward from that anchor with text-anchor start /
797
+ align left). mark.limit caps the text width so VL's autosize does not shrink
798
+ the plot.
799
+ """
800
+ axis_label_padding = charts_style.axis_y.label.padding
801
+ x_pixel = spec_width + axis_label_padding
802
+ mark = _label_mark_props(style, align="left")
803
+ if limit is not None:
804
+ mark["limit"] = limit
805
+ return {
806
+ "data": {"values": [{"__label": label_text}]},
807
+ "mark": mark,
808
+ "encoding": {
809
+ "x": {"value": x_pixel},
810
+ "y": {"value": _row_y_pixel(index, style, charts_style, spec_height)},
811
+ "text": {"field": "__label"},
812
+ # Opt out of inherited color encoding — same fix as _label_stub_left_layer.
813
+ "color": None,
814
+ },
815
+ }
816
+
817
+
818
+ def validate_data_table_against_data(
819
+ data_table: ChartDataTable,
820
+ x_field: str | None,
821
+ data: list[dict[str, Any]],
822
+ x_type: str | None = None,
823
+ ) -> int:
824
+ """Data-aware validation of a data_table block (spec §3.1, §3.2).
825
+
826
+ Runs at render time where the actual query output is available.
827
+ Complements the data-free checks in AuthoredChart.validate_data_table
828
+ (duplicate entries, chart-type, multi-y).
829
+
830
+ For temporal/quantitative x axes with more than CHART_DATA_TABLE_MAX_X_TICKS
831
+ rows, sampling is applied instead of failing. Returns the sampling step
832
+ (>= 1); step=1 means no sampling is needed.
833
+
834
+ For ordinal/nominal/unknown x_type exceeding the cap, raises ValueError
835
+ (fail-closed: dropping categories silently would be wrong).
836
+
837
+ Raises:
838
+ ValueError on violation. Messages are explicit — they name the
839
+ offending field and point at the resolution (e.g., "Add
840
+ 'aggregate: <op>'...").
841
+ """
842
+ # Spec §3.1 — x-axis must exist.
843
+ if not x_field:
844
+ raise ValueError(
845
+ "chart.data_table requires an x-encoding; the chart has no "
846
+ "`x:` field. Add `x: <column>` or remove the data_table block."
847
+ )
848
+
849
+ # Spec §3.1 — x-axis cardinality cap.
850
+ x_values = {row.get(x_field) for row in data if x_field in row}
851
+ n = len(x_values)
852
+ sampling_step = 1
853
+ if n > CHART_DATA_TABLE_MAX_X_TICKS:
854
+ if x_type in ("temporal", "quantitative"):
855
+ # Temporal/quantitative: thin the strip labels via Vega-Lite
856
+ # window+filter transforms. The chart data layers are unaffected.
857
+ # Continue validating §3.2 below — the early-return pattern would
858
+ # silently skip source-column existence and G1 multi-row checks.
859
+ sampling_step = math.ceil(n / CHART_DATA_TABLE_MAX_X_TICKS)
860
+ else:
861
+ raise ValueError(
862
+ f"chart.data_table supports at most "
863
+ f"{CHART_DATA_TABLE_MAX_X_TICKS} x-axis ticks; got "
864
+ f"{n}. Aggregate or filter in the query before "
865
+ "rendering an attached table."
866
+ )
867
+
868
+ # Spec §3.2 — every referenced column must be present in the query output.
869
+ # Use the first row as the schema sample (mirrors how the rest of the
870
+ # render pipeline reads row shape).
871
+ if data:
872
+ available = set(data[0].keys())
873
+ for entry in data_table.entries:
874
+ if isinstance(entry, ChartDataTablePerSeries):
875
+ source_col = entry.per_series
876
+ else:
877
+ source_col = entry.source
878
+ if source_col not in available:
879
+ cols = ", ".join(sorted(available))
880
+ raise ValueError(
881
+ f"chart.data_table entry references source column "
882
+ f"{source_col!r} which is not in the query output. "
883
+ f"Available columns: {cols}."
884
+ )
885
+
886
+ # Spec §3.2 G1 — a bare `source:` entry is ambiguous if the query
887
+ # returns multiple rows per x. Point the author at `aggregate:`.
888
+ # Normal per_series entries (by_measure=False) are exempt — they aggregate
889
+ # groupby [x, color] and expect multiple rows per x.
890
+ # by_measure entries are NOT exempt — they read datum[field] directly with
891
+ # no aggregation transform, so multiple rows per x produce a wrong result.
892
+ if data and x_field:
893
+ from collections import Counter
894
+
895
+ counts = Counter(row.get(x_field) for row in data)
896
+ multi_row = any(c > 1 for c in counts.values())
897
+ if multi_row:
898
+ for entry in data_table.entries:
899
+ is_aggregate = isinstance(entry, ChartDataTableAggregate)
900
+ is_normal_per_series = (
901
+ isinstance(entry, ChartDataTablePerSeries) and not entry.by_measure
902
+ )
903
+ if not is_aggregate and not is_normal_per_series:
904
+ source_col = (
905
+ entry.per_series
906
+ if isinstance(entry, ChartDataTablePerSeries)
907
+ else entry.source
908
+ )
909
+ raise ValueError(
910
+ f"chart.data_table entry references column {source_col!r} "
911
+ f"which is ambiguous on this chart — {x_field!r} "
912
+ "returns multiple rows per x. Add "
913
+ f"'aggregate: <op>' (e.g. 'aggregate: sum, "
914
+ f"source: {source_col}') to resolve."
915
+ )
916
+
917
+ return sampling_step
918
+
919
+
920
+ def _sampling_transforms(x_field: str, step: int) -> list[dict[str, Any]]:
921
+ """Vega-Lite window + filter transforms for strip-cell sampling.
922
+
923
+ Assigns a 1-indexed row_number sorted ascending by x_field, then filters
924
+ to rows where (row_number - 1) % step == 0, keeping the first row and
925
+ every step-th row after it.
926
+ """
927
+ return [
928
+ {
929
+ "window": [{"op": "row_number", "as": "__data_table_row_index"}],
930
+ "sort": [{"field": x_field, "order": "ascending"}],
931
+ },
932
+ {"filter": f"(datum.__data_table_row_index - 1) % {step} === 0"},
933
+ ]
934
+
935
+
936
+ def resolve_effective_data_table_style(
937
+ charts_style: MergedChartsStyle, chart_type: str
938
+ ) -> DataTableStyle:
939
+ """Resolve the effective data_table style for a chart.
940
+
941
+ Applies the per-chart-type override (tier 2, spec §4.1) on top of the
942
+ universal style.charts.data_table (tier 1). Tier 3 (chart-local patch)
943
+ is already merged into charts_style.data_table via pipeline.py before
944
+ this function is called.
945
+ """
946
+ base: DataTableStyle = charts_style.data_table
947
+ per_type = getattr(charts_style, chart_type, None)
948
+ if per_type is None:
949
+ return base
950
+ override = getattr(per_type, "data_table", None)
951
+ if override is None:
952
+ return base
953
+ return deep_merge(base, override) # type: ignore[return-value]
954
+
955
+
956
+ def attach_data_table(
957
+ spec: dict[str, Any],
958
+ *,
959
+ data_table: ChartDataTable | None,
960
+ style: DataTableStyle,
961
+ charts_style: MergedChartsStyle | None = None,
962
+ sampling_step: int = 1,
963
+ entry_dx: list[float] | None = None,
964
+ series_order: list[str] | None = None,
965
+ series_palette: list[str] | None = None,
966
+ label_period_filter_expr: str | None = None,
967
+ axis_y_orient: Literal["left", "right"],
968
+ formats: dict[str, str] | None = None,
969
+ ) -> dict[str, Any]:
970
+ """Append attached-data-table layers to a Vega-Lite chart spec.
971
+
972
+ Y-positioning is pixel-literal (`{"y": {"value": <px>}}`), anchored to
973
+ the spec's explicit height. Vega-Lite scales `{"y": {"expr": ...}}`
974
+ through the parent's y-scale rather than treating it as a pixel
975
+ literal, so the spec must carry an explicit height (Dataface's
976
+ standard renderer always sets one).
977
+
978
+ Args:
979
+ spec: Base Vega-Lite chart spec (mark + encoding, or already layered).
980
+ data_table: Parsed ChartDataTable block, or None for no-op.
981
+ style: Resolved DataTableStyle (already cascade-resolved).
982
+ charts_style: Resolved ChartsStyle for x-axis label offset computation.
983
+ Required when data_table is non-None.
984
+ sampling_step: When > 1, prepend a Vega-Lite window+filter transform
985
+ chain to each text-mark layer so only every Nth row renders a
986
+ cell. The chart's bar/line/area base layer is not affected.
987
+ Computed by validate_data_table_against_data for temporal/
988
+ quantitative x axes that exceed CHART_DATA_TABLE_MAX_X_TICKS.
989
+ entry_dx: Per-entry dx offsets (pixels) for band-centering. When
990
+ set, entry_dx[i] is applied as VL mark ``dx`` on the text layer
991
+ for entry i, shifting right-aligned text so the number lane's
992
+ centre aligns with the band midpoint (same invariant as
993
+ _compute_lane_positions in table.py: column centred, decimals
994
+ aligned within). Computed by the render layer from measured
995
+ max formatted widths; None means no centering (e.g. widths all zero).
996
+ series_order: For per_series entries — ordered list of series names
997
+ in the chart's stack/scale order. Required when any entry is a
998
+ ChartDataTablePerSeries. Callers resolve this from the parent
999
+ chart's color encoding domain.
1000
+ series_palette: For per_series entries — the effective palette colours
1001
+ for each series in series_order, used to resolve dark companion
1002
+ label ink. When None, label fill is omitted.
1003
+ label_period_filter_expr: Vega filter expression that restricts strip
1004
+ cells to label-period openers. When set, each text-mark layer
1005
+ receives a ``{"filter": label_period_filter_expr}`` transform so
1006
+ only x-values that open a new label period (e.g. quarterly openers
1007
+ on a monthly-band axis) emit a cell. Computed by the render layer
1008
+ from the chart's encoding_time_unit / label_time_unit pair.
1009
+ axis_y_orient: Resolved y-axis orient — ``"right"`` or ``"left"``
1010
+ (line charts with right-edge endpoint labels). Determines which label
1011
+ emitter fires: right-oriented → label at ``spec_width +
1012
+ axis_y.label.padding`` with ``align: left``; left-oriented → label at
1013
+ ``-axis_y.label.padding`` with ``align: right``. Required — callers must
1014
+ resolve this from the chart's axis_y.orient before calling.
1015
+
1016
+ Returns:
1017
+ New spec dict with attached layers appended and ``autosize`` set
1018
+ to ``pad`` so the outer SVG expands to include the strip rather
1019
+ than shrinking the plot. Padding is NOT touched — callers must
1020
+ reserve the strip's height themselves via
1021
+ ``data_table_strip_height``. Input spec not mutated.
1022
+ """
1023
+ if data_table is None or not data_table.entries:
1024
+ return spec
1025
+
1026
+ if charts_style is None:
1027
+ raise ValueError(
1028
+ "attach_data_table requires charts_style when data_table is "
1029
+ "non-None — x-axis label offset computation needs it."
1030
+ )
1031
+
1032
+ spec_height = spec.get("height")
1033
+ if not isinstance(spec_height, (int, float)) or spec_height <= 0:
1034
+ raise ValueError(
1035
+ "attach_data_table requires the spec to carry an explicit height "
1036
+ "when data_table is non-None — pixel-literal strip positioning "
1037
+ "has no anchor otherwise."
1038
+ )
1039
+
1040
+ raw_width = spec.get("width")
1041
+ spec_width = (
1042
+ float(raw_width)
1043
+ if isinstance(raw_width, (int, float)) and raw_width > 0
1044
+ else None
1045
+ )
1046
+
1047
+ # Compute the total number of visual rows.
1048
+ # by_measure entries each expand to 1 row; normal per_series entries expand
1049
+ # to N rows (one per series); source/aggregate entries are 1 row each.
1050
+ n_visual_rows = 0
1051
+ for entry in data_table.entries:
1052
+ if isinstance(entry, ChartDataTablePerSeries):
1053
+ if entry.by_measure:
1054
+ n_visual_rows += 1
1055
+ else:
1056
+ n_visual_rows += len(series_order) if series_order else 1
1057
+ else:
1058
+ n_visual_rows += 1
1059
+
1060
+ # Extract parent x-encoding before wrapping (wrap moves encoding into the base layer).
1061
+ #
1062
+ # Three shapes are accepted:
1063
+ #
1064
+ # 1. Standard top-level encoding — `spec["encoding"]["x"]` is set. The bulk
1065
+ # of charts arrive here.
1066
+ # 2. Layered VL spec with x per-layer only (no top-level encoding.x) — the
1067
+ # migrator's `type: layered` emission with multiple bar layers + chart-level
1068
+ # x + data_table renders into this shape: top-level `spec["encoding"]` is
1069
+ # empty/absent and every `spec["layer"][i]["encoding"]` carries the same
1070
+ # x. We anchor against `layer[0]`'s x.
1071
+ # 3. Any other shape — raise ChartDataError. The strip needs an unambiguous
1072
+ # anchor; silently picking layer-0's x when other layers disagree (or when
1073
+ # resolve.scale.x is independent) would misalign the strip against the
1074
+ # other layers' x-buckets, which is the "wrong result that looks right"
1075
+ # failure mode the repo non-negotiables forbid.
1076
+ _raw_top_encoding = spec.get("encoding")
1077
+ top_encoding: dict[str, Any] = (
1078
+ _raw_top_encoding if isinstance(_raw_top_encoding, dict) else {}
1079
+ )
1080
+ if "x" in top_encoding:
1081
+ parent_x_enc = top_encoding["x"]
1082
+ else:
1083
+ from dataface.core.render.errors import ChartDataError
1084
+
1085
+ layers_inline = spec.get("layer")
1086
+ if not isinstance(layers_inline, list) or not layers_inline:
1087
+ raise ChartDataError(
1088
+ "data_table attachment requires an x-encoding on the chart spec "
1089
+ "or its layers; none found."
1090
+ )
1091
+ if spec.get("resolve", {}).get("scale", {}).get("x") == "independent":
1092
+ raise ChartDataError(
1093
+ "data_table strip cannot anchor to a layered spec with "
1094
+ "resolve.scale.x == 'independent'; the layers have per-dataset "
1095
+ "x scales and the strip cannot anchor coherently."
1096
+ )
1097
+ layer_x_encs = [
1098
+ layer.get("encoding", {}).get("x")
1099
+ for layer in layers_inline
1100
+ if isinstance(layer, dict)
1101
+ ]
1102
+ non_null_x_encs = [enc for enc in layer_x_encs if isinstance(enc, dict)]
1103
+ if not non_null_x_encs:
1104
+ raise ChartDataError(
1105
+ "data_table attachment requires an x-encoding on the chart spec "
1106
+ "or its layers; none found."
1107
+ )
1108
+ first_x_field = non_null_x_encs[0].get("field")
1109
+ for enc in non_null_x_encs[1:]:
1110
+ if enc.get("field") != first_x_field:
1111
+ raise ChartDataError(
1112
+ "data_table strip cannot anchor to a layered spec whose "
1113
+ "per-layer x-encodings disagree on field; lift x to chart "
1114
+ "level or remove data_table."
1115
+ )
1116
+ parent_x_enc = non_null_x_encs[0]
1117
+ # Resolve the color field from the parent spec's top-level encoding for
1118
+ # per_series entries. In the layered fallback case top-level encoding has
1119
+ # no color (color lives per-layer); return None and let the per_series
1120
+ # caller require an explicit series_order/series_palette.
1121
+ parent_color_enc = top_encoding.get("color")
1122
+ color_field: str | None = None
1123
+ if isinstance(parent_color_enc, dict):
1124
+ color_field = parent_color_enc.get("field")
1125
+
1126
+ out = _wrap_base_as_layer(spec)
1127
+ layers: list[dict[str, Any]] = list(out["layer"])
1128
+
1129
+ if style.divider.width > 0:
1130
+ layers.append(_divider_rule_layer(style, charts_style, spec_height))
1131
+
1132
+ _label_stub_limit: float = _LABEL_STUB_LIMIT_PX
1133
+
1134
+ # Track the current visual row index as we iterate entries. Source and
1135
+ # aggregate entries each consume 1 row; per_series entries consume N rows.
1136
+ visual_row_idx = 0
1137
+ for i, entry in enumerate(data_table.entries):
1138
+ row_dx = entry_dx[i] if entry_dx is not None else None
1139
+ if isinstance(entry, ChartDataTablePerSeries):
1140
+ if entry.by_measure:
1141
+ # by_measure: one row per entry, no series expansion needed.
1142
+ bm_layers = _per_series_row_layers(
1143
+ visual_row_idx,
1144
+ entry,
1145
+ parent_x_enc=parent_x_enc,
1146
+ color_field=None,
1147
+ series_order=[],
1148
+ series_palette=None,
1149
+ style=style,
1150
+ charts_style=charts_style,
1151
+ spec_height=spec_height,
1152
+ spec_width=spec_width,
1153
+ sampling_step=sampling_step,
1154
+ dx=row_dx,
1155
+ label_period_filter_expr=label_period_filter_expr,
1156
+ axis_y_orient=axis_y_orient,
1157
+ label_limit=_label_stub_limit,
1158
+ )
1159
+ layers.extend(bm_layers)
1160
+ if style.row.rule.width > 0 and visual_row_idx < n_visual_rows - 1:
1161
+ layers.append(
1162
+ _row_rule_layer(
1163
+ visual_row_idx, style, charts_style, spec_height
1164
+ )
1165
+ )
1166
+ visual_row_idx += 1
1167
+ else:
1168
+ if not series_order:
1169
+ raise ValueError(
1170
+ "attach_data_table requires series_order when per_series "
1171
+ "entries are present — callers must supply the ordered series "
1172
+ "list resolved from the parent chart's color encoding domain."
1173
+ )
1174
+ if color_field is None:
1175
+ raise ValueError(
1176
+ "attach_data_table requires the spec to carry a color "
1177
+ "encoding when per_series entries are present."
1178
+ )
1179
+ per_series_layers = _per_series_row_layers(
1180
+ visual_row_idx,
1181
+ entry,
1182
+ parent_x_enc=parent_x_enc,
1183
+ color_field=color_field,
1184
+ series_order=series_order,
1185
+ series_palette=series_palette,
1186
+ style=style,
1187
+ charts_style=charts_style,
1188
+ spec_height=spec_height,
1189
+ spec_width=spec_width,
1190
+ sampling_step=sampling_step,
1191
+ dx=row_dx,
1192
+ label_period_filter_expr=label_period_filter_expr,
1193
+ axis_y_orient=axis_y_orient,
1194
+ label_limit=_label_stub_limit,
1195
+ formats=formats,
1196
+ )
1197
+ layers.extend(per_series_layers)
1198
+ n_series = len(series_order)
1199
+ # Inter-row rules: emit ONE per pair of adjacent visual rows
1200
+ # within the per_series block (n_series - 1 rules) plus one
1201
+ # trailing rule between this block and the next entry — same
1202
+ # cadence as source/aggregate rows, where every adjacent visual
1203
+ # pair gets a rule. Without the within-block rules, the strip
1204
+ # height accounting (which includes inter-row spacing for every
1205
+ # visual row) reserves gaps where no rules are drawn.
1206
+ if style.row.rule.width > 0:
1207
+ last_in_block = visual_row_idx + n_series - 1
1208
+ # Within-block rules: between each pair of adjacent series.
1209
+ for inner_idx in range(visual_row_idx, last_in_block):
1210
+ layers.append(
1211
+ _row_rule_layer(inner_idx, style, charts_style, spec_height)
1212
+ )
1213
+ # Trailing rule: between this block and the next entry.
1214
+ if last_in_block < n_visual_rows - 1:
1215
+ layers.append(
1216
+ _row_rule_layer(
1217
+ last_in_block, style, charts_style, spec_height
1218
+ )
1219
+ )
1220
+ visual_row_idx += n_series
1221
+ else:
1222
+ row_layer = _row_text_layer(
1223
+ visual_row_idx,
1224
+ entry,
1225
+ parent_x_enc=parent_x_enc,
1226
+ style=style,
1227
+ charts_style=charts_style,
1228
+ spec_height=spec_height,
1229
+ sampling_step=sampling_step,
1230
+ dx=row_dx,
1231
+ label_period_filter_expr=label_period_filter_expr,
1232
+ formats=formats,
1233
+ )
1234
+ layers.append(row_layer)
1235
+ label = getattr(entry, "label", None)
1236
+ if label:
1237
+ if axis_y_orient == "right":
1238
+ if spec_width is None:
1239
+ raise ValueError(
1240
+ "attach_data_table requires the spec to carry an explicit "
1241
+ "width when right-cap labels are present — pixel-literal "
1242
+ "label positioning has no anchor otherwise."
1243
+ )
1244
+ layers.append(
1245
+ _label_stub_right_layer(
1246
+ visual_row_idx,
1247
+ label,
1248
+ style=style,
1249
+ charts_style=charts_style,
1250
+ spec_height=spec_height,
1251
+ spec_width=spec_width,
1252
+ limit=_label_stub_limit,
1253
+ )
1254
+ )
1255
+ else:
1256
+ layers.append(
1257
+ _label_stub_left_layer(
1258
+ visual_row_idx,
1259
+ label,
1260
+ style=style,
1261
+ charts_style=charts_style,
1262
+ spec_height=spec_height,
1263
+ limit=_label_stub_limit,
1264
+ )
1265
+ )
1266
+ if style.row.rule.width > 0 and visual_row_idx < n_visual_rows - 1:
1267
+ layers.append(
1268
+ _row_rule_layer(visual_row_idx, style, charts_style, spec_height)
1269
+ )
1270
+ visual_row_idx += 1
1271
+
1272
+ out["layer"] = layers
1273
+ # Strip rows live outside the plot area (pixel y > spec.height for bottom;
1274
+ # pixel y < 0 for top). Override the chart-wide `autosize: fit` default with
1275
+ # `pad` so the outer SVG grows to include the strip rather than shrinking the plot. Trade-off: under
1276
+ # `pad`, spec.width refers to the inner plot, so the outer SVG is wider
1277
+ # than the requested width by axis-y label + padding overhead (constant
1278
+ # regardless of requested width — see
1279
+ # test_render_pipeline_svg_width_overhead_independent_of_requested_width).
1280
+ # layout_sizing.py compensates symmetrically on both axes. The initial
1281
+ # render-first sizing pass (_make_data_aware_height_provider) renders once
1282
+ # to measure the width overhead, then re-renders with spec.width pre-shrunk.
1283
+ # Cols alignment (_align_cols_heights) has a hard target_height, so after
1284
+ # its alignment re-render it measures any height overhead and re-renders
1285
+ # with spec.height pre-shrunk too. Both axes fit the slot.
1286
+ out["autosize"] = {"type": "pad", "contains": "padding"}
1287
+ return out