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,3438 @@
1
+ """Chart profile mapping: Dataface canonical → Vega-Lite-native concepts.
2
+
3
+ This module is the single home for all Dataface/Vega-Lite divergence:
4
+ - chart type renames (scatter→point, pie→arc, etc.)
5
+ - channel encoding construction from resolved Dataface fields
6
+ - structural transforms (horizontal bar orientation swap)
7
+ - sort mapping to Vega-Lite sort properties
8
+ - bar axis categorical defaults
9
+ - per-family encoding mapping (histogram bin, boxplot composite, arc theta, etc.)
10
+
11
+ Every Vega-Lite-native chart type is either:
12
+ - **profiled**: enters through ``map_to_vega_lite`` which returns a ``MappedChart``
13
+ - **exception**: geo/kpi families that bypass profile with documented reasons
14
+
15
+ The standard_renderer consumes the MappedChart output and performs
16
+ mechanical Vega-Lite spec assembly without further profile logic.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from collections.abc import Callable, Iterable
24
+ from dataclasses import dataclass, field, replace
25
+ from decimal import Decimal
26
+ from typing import TYPE_CHECKING, Any, Literal
27
+
28
+ from dataface.core.compile.channel import ResolvedStyleChannel, parse_style_channel
29
+ from dataface.core.compile.chart_resolved import (
30
+ ResolvedChart,
31
+ effective_color_field,
32
+ is_grouped_bar,
33
+ )
34
+ from dataface.core.compile.models.chart.authored import ConditionalRule
35
+ from dataface.core.compile.models.style.compiled import AxisStylePatch
36
+ from dataface.core.compile.models.style.merged import MergedChartsStyle, resolve_mark
37
+ from dataface.core.compile.palette import resolve_dark_companion_stops
38
+ from dataface.core.compile.style_cascade import (
39
+ _chart_type_axis_patch,
40
+ resolved_axis_style,
41
+ )
42
+ from dataface.core.render.chart.tick_values import (
43
+ nice_tick_values,
44
+ stacked_bar_totals_max,
45
+ )
46
+ from dataface.core.render.chart.time_unit_detect import (
47
+ BUCKETED_CALENDAR_UNITS,
48
+ complete_ordinal_time_series,
49
+ default_label_expr_for,
50
+ detect_time_unit,
51
+ ordinal_axis_values,
52
+ resolve_label_time_unit,
53
+ vl_time_unit,
54
+ )
55
+ from dataface.core.render.chart.type_inference import infer_vega_type_from_data
56
+ from dataface.core.render.chart.vl_field_maps import map_fields
57
+ from dataface.core.render.errors import ChartDataError, UnknownChartType
58
+ from dataface.core.render.format_utils import resolve_format
59
+ from dataface.core.render.text.case import format_display_text
60
+
61
+ if TYPE_CHECKING:
62
+ from dataface.core.compile.custom_chart_types import CustomChartTypeRegistry
63
+
64
+ # Default band-scale padding for grouped bar charts (xOffset/yOffset mode).
65
+ # Theme YAML has no grouped-vs-single granularity, so these live here.
66
+ # paddingInner: gap between groups; paddingOuter: margin at both plot edges.
67
+ _GROUPED_BAR_PADDING_INNER: float = 0.2
68
+ _GROUPED_BAR_PADDING_OUTER: float = 0.2
69
+
70
+
71
+ # Invisible per-datum point overlay added to line/area charts so the JS hover
72
+ # layer resolves to individual data points instead of the single line/area path.
73
+ # size=300 ≈ 9.7 px hit radius (VL size = area in sq px; sqrt(300/π) ≈ 9.77 px).
74
+ # Returns a fresh dict each call — callers must not share the same mark dict
75
+ # across layers (mutations in downstream spec assembly would corrupt all callers).
76
+ def _hover_overlay_point() -> dict[str, Any]:
77
+ return {
78
+ "type": "point",
79
+ "filled": True,
80
+ "size": 300,
81
+ "opacity": 0,
82
+ "tooltip": True,
83
+ }
84
+
85
+
86
+ _OP_MAP: dict[str, str] = {
87
+ "eq": "==",
88
+ "ne": "!=",
89
+ "lt": "<",
90
+ "lte": "<=",
91
+ "gt": ">",
92
+ "gte": ">=",
93
+ }
94
+
95
+ # Detect any d3-time-format directive (% optionally followed by a padding
96
+ # modifier and a letter). %% is a literal-percent escape and is stripped first
97
+ # so "%%Y" is treated as the four characters %%Y, not a directive.
98
+ # d3-time-format supports padding modifiers between % and the directive letter:
99
+ # - "-" suppresses padding (e.g. %-d → "9" not "09")
100
+ # - "_" pads with spaces (e.g. %_d → " 9")
101
+ # - "0" pads with zeros (e.g. %0H, the default)
102
+ # Common real-world formats like "%-m/%-d/%-Y" contain no bare %<letter>, so
103
+ # the regex must accept an optional padding modifier.
104
+ _TIME_FORMAT_RE = re.compile(r"%[-_0]?[A-Za-z]")
105
+
106
+
107
+ def _utc_time_label_expr(fmt: str) -> str:
108
+ """Build a Vega ``labelExpr`` that formats a date-string axis tick under UTC.
109
+
110
+ Axis-and-scale-agnostic: ``toDate(datum.value)`` parses ISO date strings
111
+ (ordinal domain) or accepts Date instances unchanged (temporal/quantitative
112
+ domain); ``utcFormat`` then emits the requested d3-time-format string in
113
+ UTC, so the spec renders identically under any runtime TZ.
114
+
115
+ Replaces both legacy paths that routed time-format strings through
116
+ ``formatType: "time"`` (d3-time-format under runtime-local TZ) — the
117
+ ordinal-temporal-inferred axis-x branch and the non-temporal y/x fallback.
118
+ """
119
+ fmt_escaped = fmt.replace("\\", "\\\\").replace("'", "\\'")
120
+ return f"utcFormat(toDate(datum.value), '{fmt_escaped}')"
121
+
122
+
123
+ def _is_time_format(fmt: str) -> bool:
124
+ """Return True when fmt contains a d3-time-format directive (``%<letter>``).
125
+
126
+ Ordinal axes in Vega use d3-format (number formatting). When a time-format
127
+ string like ``"%b %Y"`` is applied to an ordinal axis, d3-format rejects it,
128
+ causing a Vega scene-graph failure. Callers use this to route the format
129
+ through ``_utc_time_label_expr`` instead.
130
+
131
+ ``%%`` is a literal-percent escape and is stripped before the search so
132
+ ``"%%Y"`` (the literal four-character sequence) does not trigger detection.
133
+ """
134
+ return bool(_TIME_FORMAT_RE.search(fmt.replace("%%", "")))
135
+
136
+
137
+ def _rule_to_vl_test(rule: Any, field_ref: str) -> str:
138
+ """Build a Vega-Lite ``condition.test`` expression for one rule.
139
+
140
+ Binary ops map to ``_OP_MAP``. Extended predicates expand:
141
+ - ``between [a, b]`` → ``(field >= a) && (field <= b)``
142
+ - ``in [v1, v2, ...]``→ ``indexof([...], field) >= 0``
143
+ - ``is_null: true`` → ``field == null``
144
+ - ``is_null: false`` → ``field != null``
145
+ - ``default: true`` → ``true`` (always matches)
146
+ """
147
+ if rule.default is True:
148
+ return "true"
149
+ if rule.is_null is True:
150
+ return f"{field_ref} == null"
151
+ if rule.is_null is False:
152
+ return f"{field_ref} != null"
153
+ if rule.between is not None:
154
+ low, high = rule.between
155
+ return (
156
+ f"({field_ref} >= {json.dumps(low)}) && ({field_ref} <= {json.dumps(high)})"
157
+ )
158
+ if rule.in_ is not None:
159
+ return f"indexof({json.dumps(list(rule.in_))}, {field_ref}) >= 0"
160
+ pred_field, pred_val = rule.active_predicate()
161
+ return f"{field_ref} {_OP_MAP[pred_field]} {json.dumps(pred_val)}"
162
+
163
+
164
+ # ── Chart type mapping ────────────────────────────────────────────────
165
+ # Maps Dataface chart type names to Vega-Lite mark types.
166
+ # Identity entries included for completeness — every supported type
167
+ # should be listed so lookup never falls through silently.
168
+
169
+ CHART_TYPE_MAP: dict[str, str] = {
170
+ "bar": "bar",
171
+ "line": "line",
172
+ "area": "area",
173
+ "circle": "circle",
174
+ "square": "square",
175
+ "text": "text",
176
+ "tick": "tick",
177
+ "rule": "rule",
178
+ "trail": "trail",
179
+ "rect": "rect",
180
+ "arc": "arc",
181
+ "boxplot": "boxplot",
182
+ "errorbar": "errorbar",
183
+ "errorband": "errorband",
184
+ "geoshape": "geoshape",
185
+ "image": "image",
186
+ "map": "geoshape",
187
+ "point_map": "circle",
188
+ "bubble_map": "circle",
189
+ "scatter": "point",
190
+ "heatmap": "rect",
191
+ "pie": "arc",
192
+ "histogram": "bar",
193
+ "kpi": "text",
194
+ }
195
+
196
+ # Data-series layer types that receive datum-color injection in layered charts.
197
+ # Annotation/overlay marks (text, rule, tick, image) are excluded so they don't
198
+ # appear as spurious legend entries.
199
+ _DATA_SERIES_LAYER_TYPES: frozenset[str] = frozenset(
200
+ {"bar", "line", "area", "circle", "square", "scatter", "trail", "rect"}
201
+ )
202
+
203
+ # ── Profile routing classification ───────────────────────────────────
204
+ # Every type in CHART_TYPE_MAP is classified into exactly one set.
205
+ #
206
+ # PROFILED types enter through map_to_vega_lite() which returns a
207
+ # MappedChart with mark + encoding. The standard_renderer assembles
208
+ # the final spec mechanically from the MappedChart.
209
+ #
210
+ # EXCEPTION types bypass profile for documented reasons:
211
+ # - geo (map/geoshape/point_map/bubble_map): Dataface adds
212
+ # product value beyond Vega-Lite (built-in geo sources, data joins,
213
+ # projection defaults). The renderer needs geo-specific data plumbing
214
+ # that the cartesian profile seam cannot express.
215
+ # - kpi: Non-Vega-Lite renderer. Produces a text mark with formatted
216
+ # single-number display logic that has no mapping analog.
217
+
218
+ PROFILED_CHART_FAMILIES: frozenset[str] = frozenset(
219
+ {
220
+ # Standard cartesian single-series
221
+ "bar",
222
+ "line",
223
+ "area",
224
+ "circle",
225
+ "square",
226
+ "text",
227
+ "tick",
228
+ "rule",
229
+ "trail",
230
+ "image",
231
+ "scatter",
232
+ # Composite / statistical marks
233
+ "histogram",
234
+ "boxplot",
235
+ "errorbar",
236
+ "errorband",
237
+ # Non-cartesian marks
238
+ "arc",
239
+ "pie",
240
+ # Grid / matrix marks
241
+ "rect",
242
+ "heatmap",
243
+ }
244
+ )
245
+
246
+ EXCEPTION_CHART_FAMILIES: frozenset[str] = frozenset(
247
+ {
248
+ # Geo: built-in geo sources, data joins, projection defaults
249
+ "map",
250
+ "geoshape",
251
+ "point_map",
252
+ "bubble_map",
253
+ # KPI: non-Vega-Lite single-number renderer
254
+ "kpi",
255
+ }
256
+ )
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class MappedLayer:
261
+ """A single layer in a profiled layered chart."""
262
+
263
+ mark: dict[str, Any]
264
+ encoding: dict[str, Any] = field(default_factory=dict)
265
+ data_name: str | None = None
266
+ transform: tuple[dict[str, Any], ...] = ()
267
+ # Inline data override for layers that must NOT inherit the spec's
268
+ # ``data.values``. Used by the zero/top-rule layers to emit one rule
269
+ # mark instead of one per parent data row.
270
+ data: dict[str, Any] | None = None
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class MappedChart:
275
+ """Vega-Lite-native chart after profile mapping from Dataface canonical form.
276
+
277
+ Single-series charts use ``mark`` + ``encoding``.
278
+ Layered charts keep shared top-level ``encoding`` and a concrete ``layers`` list.
279
+
280
+ When layers use per-layer queries, ``datasets`` maps query names to their
281
+ row data. The spec generator emits VL ``"datasets"`` and per-layer
282
+ ``"data": {"name": ...}`` references.
283
+
284
+ ``data_override`` lets a profile add synthetic columns onto the top-level
285
+ inline data (e.g. donut labels pre-render Jinja templates per row). The
286
+ spec generator substitutes this for the original data at the top-level
287
+ ``data.values`` key.
288
+ """
289
+
290
+ mark: dict[str, Any] | None = None
291
+ encoding: dict[str, Any] = field(default_factory=dict)
292
+ layers: tuple[MappedLayer, ...] = ()
293
+ datasets: dict[str, list[dict[str, Any]]] | None = None
294
+ data_override: list[dict[str, Any]] | None = None
295
+ transform: tuple[dict[str, Any], ...] = ()
296
+ # Resolve clauses derived from typed surface (e.g. per-layer axis_y.orient
297
+ # auto-promotes the chart to independent y scales).
298
+ derived_resolve: dict[str, Any] | None = None
299
+
300
+
301
+ # ── Zero-baseline rule mark ──────────────────────────────────────────
302
+ # Vega renders axis guides (grid lines) BELOW marks, so area/bar fills cover
303
+ # the bold zero baseline produced by the conditional ``gridColor`` encoding.
304
+ # A rule mark layer at the top of the layer stack renders above all fills.
305
+ # Scoped to bar (vertical + horizontal), area, and line charts; skipped when
306
+ # 0 isn't in the measure-axis domain or when that axis's grid is hidden.
307
+
308
+
309
+ def _domain_includes_zero(
310
+ resolved_chart: ResolvedChart,
311
+ data: list[dict[str, Any]],
312
+ field: str,
313
+ axis_name: str,
314
+ ) -> bool:
315
+ """True when 0 falls within the effective scale domain on ``axis_name``.
316
+
317
+ Precedence: explicit per-axis ``axis.scale.domain`` → chart-level
318
+ ``scale.domain`` → ``resolved_chart.zero`` (inferred or authored chart-level
319
+ zero override, written to the VL y-encoding by ``map_y_encoding``) →
320
+ per-axis / chart-level ``scale.zero`` from the style cascade → data range
321
+ fallback.
322
+
323
+ ``resolved_chart.zero`` must be checked before the style cascade because the
324
+ pipeline infers ``zero=False`` for all-positive-data line/area charts (the
325
+ inference writes it to the VL y-encoding as ``scale.zero: false``). The style
326
+ cascade holds the *theme* scale zero, which is almost always None; a plain
327
+ ``None is not False`` guard would therefore misidentify inferred-False as
328
+ "unset" and fire the zero rule even when the rendering scale excludes 0.
329
+ """
330
+ rs = resolved_chart.resolved_style
331
+ # Walk the cascade to get the per-axis scale (chart-local axis_y.scale wins
332
+ # over theme axis_y.scale via resolved_axis_style).
333
+ merged_axis = resolved_axis_style(rs, axis_name, "quantitative") # type: ignore[arg-type]
334
+ per_axis_scale = merged_axis.scale
335
+ chart_level_scale = rs.scale
336
+ # 1. Explicit per-axis or chart-level scale.domain
337
+ for scale in (per_axis_scale, chart_level_scale):
338
+ domain = getattr(scale, "domain", None) if scale is not None else None
339
+ if isinstance(domain, list) and len(domain) == 2:
340
+ lo, hi = domain
341
+ if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
342
+ return lo <= 0 <= hi
343
+ # 2. Explicit scale.zero — resolved_chart.zero wins (written to the VL encoding
344
+ # by map_y_encoding), then per-axis style scale, then chart-level style scale.
345
+ zero_setting: bool | None = resolved_chart.zero
346
+ if zero_setting is None:
347
+ for scale in (per_axis_scale, chart_level_scale):
348
+ z = getattr(scale, "zero", None) if scale is not None else None
349
+ if z is not None:
350
+ zero_setting = z
351
+ break
352
+ # When scale.zero is unset, the rule layer's ``datum: 0`` enters the unified
353
+ # VL domain and pulls 0 in regardless of data dtype, so we don't need to scan
354
+ # rows. ``scale.zero=False`` is the only path that requires a per-row check.
355
+ if zero_setting is not False:
356
+ return True
357
+ # 3. scale.zero=False explicitly: emit the rule only when data straddles 0.
358
+ if not data:
359
+ return False
360
+ values: list[float] = []
361
+ for row in data:
362
+ v = row.get(field)
363
+ if isinstance(v, (int, float, Decimal)) and not isinstance(v, bool):
364
+ values.append(float(v))
365
+ elif isinstance(v, str):
366
+ try:
367
+ values.append(float(v))
368
+ except ValueError:
369
+ continue
370
+ if not values:
371
+ return False
372
+ return min(values) <= 0 <= max(values)
373
+
374
+
375
+ _ZERO_RULE_CHART_TYPES: frozenset[str] = frozenset({"area", "bar", "layered", "line"})
376
+
377
+
378
+ def _zero_rule_layer(
379
+ resolved_chart: ResolvedChart, data: list[dict[str, Any]]
380
+ ) -> MappedLayer | None:
381
+ """Build a zero-baseline rule layer for the chart, or ``None`` to skip.
382
+
383
+ Vertical bar / area / line charts emit a horizontal rule at y=0; horizontal
384
+ bars emit a vertical rule at x=0. Returns ``None`` when:
385
+ - chart type isn't in ``_ZERO_RULE_CHART_TYPES`` (bar, area, line), OR
386
+ - chart has no quantitative measure axis (``y`` is None / list), OR
387
+ - 0 is outside the effective measure-axis domain, OR
388
+ - the quantitative grid is hidden (theme or chart-local).
389
+ """
390
+ if resolved_chart.chart_type not in _ZERO_RULE_CHART_TYPES:
391
+ return None
392
+ measure_field = resolved_chart.y
393
+ if resolved_chart.chart_type == "layered" and not isinstance(measure_field, str):
394
+ measure_field = next(
395
+ (layer.y for layer in resolved_chart.layers if layer.y), None
396
+ )
397
+ if not isinstance(measure_field, str):
398
+ return None
399
+ if not _domain_includes_zero(
400
+ resolved_chart, data, field=measure_field, axis_name="axis_y"
401
+ ):
402
+ return None
403
+
404
+ is_horizontal = (
405
+ resolved_chart.chart_type == "bar"
406
+ and resolved_chart.orientation == "horizontal"
407
+ )
408
+ effective = resolved_chart.resolved_style
409
+ chart_type = resolved_chart.chart_type
410
+
411
+ # Quantitative-measure cascade: axis_x cascade = categorical axis, axis_y
412
+ # cascade = measure axis (VL y for vertical bar, VL x for horizontal bar).
413
+ # This cascade owns the rule's grid.zero.color / zero.width and the hidden
414
+ # gate. Chart-local patches are pre-merged into ``effective`` by
415
+ # build_resolved_style, so the axis cascade reads from a single resolved source.
416
+ measure = resolved_axis_style(
417
+ effective,
418
+ "axis_y",
419
+ "quantitative",
420
+ _chart_type_axis_patch(effective, chart_type, "axis_y"),
421
+ )
422
+ if not measure.grid.visible:
423
+ return None
424
+ grid = measure.grid
425
+ assert grid.zero is not None, "theme must supply axis.grid.zero"
426
+
427
+ # Tick / orient cascade for the rule's extension over rendered axis ticks.
428
+ # Vertical bar / area: same cascade as the measure (axis_y, quantitative).
429
+ # Horizontal bar: axis_x cascade (categorical axis = VL y for horizontal
430
+ # bar); the zero rule at x=0 extends over the categorical y-axis ticks.
431
+ tick_axis = (
432
+ resolved_axis_style(
433
+ effective,
434
+ "axis_x",
435
+ "nominal",
436
+ _chart_type_axis_patch(effective, chart_type, "axis_x"),
437
+ )
438
+ if is_horizontal
439
+ else measure
440
+ )
441
+ ticks = tick_axis.ticks
442
+ visible_tick_size = (
443
+ ticks.size
444
+ if ticks.visible and ticks.size is not None and ticks.size > 0
445
+ else None
446
+ )
447
+
448
+ mark: dict[str, Any] = {
449
+ "type": "rule",
450
+ "color": grid.zero.color,
451
+ "strokeWidth": grid.zero.width,
452
+ "opacity": 1,
453
+ "tooltip": False,
454
+ }
455
+
456
+ # Single-row data override: VL inherits the spec's ``data.values`` for
457
+ # layers that don't declare their own data, so a rule encoded with all
458
+ # constant channels still iterates once per parent row at identical
459
+ # pixel coords (4 categories → 4 stacked rules; 16 weeks → 16). One
460
+ # synthetic row halts the iteration without changing the geometry.
461
+ # The row must carry the measure field name with a finite value because
462
+ # VL appends a ``isValid(datum[measure]) && isFinite(+datum[measure])``
463
+ # filter derived from the inherited shared encoding — an empty row gets
464
+ # filtered out and the rule never reaches the SVG.
465
+ rule_data: dict[str, Any] = {"values": [{measure_field: 0}]}
466
+
467
+ if is_horizontal:
468
+ # Vertical rule at x=0 spanning y=0..height. The x-axis renders ticks
469
+ # outward: below y=height (bottom) or above y=0 (top).
470
+ #
471
+ # Top-orient: encoding-level y is set to -tick_size (not mark.yOffset)
472
+ # because encoding.yOffset={value:0} (the grouped-bar neutralizer below)
473
+ # would override any mark.yOffset, silently dropping the extension.
474
+ # Bottom-orient: mark-level y2Offset extends the end past y=height.
475
+ # y2Offset is safe from neutralization: grouped horizontal bars emit a
476
+ # top-level yOffset encoding (for bar center shift) but never a y2Offset
477
+ # encoding, so the rule layer never inherits a conflicting y2Offset value.
478
+ # {expr: "height + N"} encoding is NOT an alternative — vl-convert renders
479
+ # it as 0 (expression evaluated in the wrong context).
480
+ y_start: int | float = 0
481
+ if visible_tick_size is not None:
482
+ if tick_axis.orient == "top":
483
+ y_start = -visible_tick_size # shift encoding.y up to cover ticks
484
+ else: # bottom (default)
485
+ mark["y2Offset"] = visible_tick_size # extend below y=height
486
+ mark["clip"] = False
487
+ encoding_h: dict[str, Any] = {
488
+ "x": {"datum": 0, "type": "quantitative"},
489
+ # Span the full plot height as a vertical rule; explicit y/y2
490
+ # and color overrides prevent inheriting shared encoding.
491
+ "y": {"value": y_start},
492
+ "y2": {"value": "height"},
493
+ "color": {"value": grid.zero.color},
494
+ # Grouped horizontal bars emit a top-level yOffset encoding.
495
+ # The rule data has no offset field, so VegaLite would inherit
496
+ # an undefined offset, misplacing the rule. Neutralize it.
497
+ "yOffset": {"value": 0},
498
+ }
499
+ _neutralize_layer_channels(encoding_h, resolved_chart)
500
+ return MappedLayer(mark=mark, data=rule_data, encoding=encoding_h)
501
+
502
+ # Vertical bar / area: horizontal rule at y=0 spanning x=0..width. The
503
+ # y-axis renders ticks outward: right of x=width (right-orient) or left of
504
+ # x=0 (left).
505
+ #
506
+ # Left-orient: encoding-level x is set to -tick_size (not mark.xOffset)
507
+ # because encoding.xOffset={value:0} (the grouped-bar neutralizer below)
508
+ # overrides any mark.xOffset, silently dropping the extension.
509
+ # Right-orient: mark-level x2Offset extends the end past x=width.
510
+ # x2Offset is safe from neutralization: grouped vertical bars emit a
511
+ # top-level xOffset encoding (for bar center shift) but never an x2Offset
512
+ # encoding, so the rule layer never inherits a conflicting x2Offset value.
513
+ # {expr: "width + N"} encoding is NOT an alternative — vl-convert renders
514
+ # it as 0 (expression evaluated in the wrong context).
515
+ x_start: int | float = 0
516
+ if visible_tick_size is not None:
517
+ y_orient = _resolve_orient_auto(tick_axis.orient, resolved_chart, effective)
518
+ if y_orient == "left":
519
+ x_start = -visible_tick_size # shift encoding.x left to cover ticks
520
+ else: # right (Dataface default)
521
+ mark["x2Offset"] = visible_tick_size # extend right of x=width
522
+ mark["clip"] = False
523
+ encoding: dict[str, Any] = {
524
+ "y": {"datum": 0, "type": "quantitative"},
525
+ # Override every shared channel that would otherwise fragment the
526
+ # rule into per-datum / per-series rules: x→constant pixel, x2→full
527
+ # plot width, color→explicit value (else inherits series binding).
528
+ "x": {"value": x_start},
529
+ "x2": {"value": "width"},
530
+ "color": {"value": grid.zero.color},
531
+ # Grouped vertical bars emit a top-level xOffset encoding.
532
+ # The rule data has no offset field, so VegaLite would inherit
533
+ # an undefined offset, misplacing the rule. Neutralize it.
534
+ "xOffset": {"value": 0},
535
+ }
536
+ # Only neutralize channels when the chart actually emits them — otherwise
537
+ # the override leaks spurious attributes onto the rule for every chart on
538
+ # every theme, including ones with no dashes.
539
+ _neutralize_layer_channels(encoding, resolved_chart)
540
+ return MappedLayer(mark=mark, data=rule_data, encoding=encoding)
541
+
542
+
543
+ def zero_rule_vl_layer(
544
+ resolved_chart: ResolvedChart, data: list[dict[str, Any]]
545
+ ) -> dict[str, Any] | None:
546
+ """Public-API form of the zero-baseline rule: a VL layer dict ready to drop
547
+ into ``spec["layer"]``.
548
+
549
+ Returns ``None`` when the rule should be skipped (chart type doesn't apply,
550
+ 0 not in y-domain, grid hidden). Used by the standard renderer to inject
551
+ the rule post-spec, so single-mark charts can keep their tooltip-at-spec-
552
+ encoding placement and only the structural change (mark → layer[0]) is
553
+ applied to the spec.
554
+ """
555
+ layer = _zero_rule_layer(resolved_chart, data)
556
+ if layer is None:
557
+ return None
558
+ out: dict[str, Any] = {"mark": dict(layer.mark), "encoding": dict(layer.encoding)}
559
+ if layer.data is not None:
560
+ out["data"] = dict(layer.data)
561
+ return out
562
+
563
+
564
+ def _top_rule_layer(resolved_chart: ResolvedChart) -> MappedLayer | None:
565
+ """Build a 100%-baseline rule layer for normalize-stacked charts, or None.
566
+
567
+ Mirror of ``_zero_rule_layer`` for the y=1 (or x=1 on horizontal bar)
568
+ edge that every column reaches when ``stack: 'normalize'`` is in
569
+ effect. Same color/stroke contract as the zero rule — both anchor edges
570
+ of the normalized domain with the same visual emphasis.
571
+
572
+ Returns None when the chart isn't a normalize-stacked area or bar, or
573
+ when the cascade hides the quantitative grid.
574
+ """
575
+ if resolved_chart.chart_type not in _ZERO_RULE_CHART_TYPES:
576
+ return None
577
+ if resolved_chart.stack != "normalize":
578
+ return None
579
+ measure_field = resolved_chart.y
580
+ if not isinstance(measure_field, str):
581
+ return None
582
+
583
+ is_horizontal = (
584
+ resolved_chart.chart_type == "bar"
585
+ and resolved_chart.orientation == "horizontal"
586
+ )
587
+ effective = resolved_chart.resolved_style
588
+ chart_type = resolved_chart.chart_type
589
+
590
+ measure = resolved_axis_style(
591
+ effective,
592
+ "axis_y",
593
+ "quantitative",
594
+ _chart_type_axis_patch(effective, chart_type, "axis_y"),
595
+ )
596
+ if not measure.grid.visible:
597
+ return None
598
+ grid = measure.grid
599
+ assert grid.zero is not None, "theme must supply axis.grid.zero"
600
+
601
+ mark: dict[str, Any] = {
602
+ "type": "rule",
603
+ "color": grid.zero.color,
604
+ "strokeWidth": grid.zero.width,
605
+ "opacity": 1,
606
+ "tooltip": False,
607
+ }
608
+
609
+ # See _zero_rule_layer for the rationale on the measure-field synthetic row.
610
+ rule_data: dict[str, Any] = {"values": [{measure_field: 0}]}
611
+
612
+ if is_horizontal:
613
+ encoding_h: dict[str, Any] = {
614
+ "x": {"datum": 1, "type": "quantitative"},
615
+ "y": {"value": 0},
616
+ "y2": {"value": "height"},
617
+ "color": {"value": grid.zero.color},
618
+ "yOffset": {"value": 0},
619
+ }
620
+ _neutralize_layer_channels(encoding_h, resolved_chart)
621
+ return MappedLayer(mark=mark, data=rule_data, encoding=encoding_h)
622
+
623
+ # Vertical bar / area: horizontal rule at y=1 spanning x=0..width.
624
+ encoding_v: dict[str, Any] = {
625
+ "y": {"datum": 1, "type": "quantitative"},
626
+ "x": {"value": 0},
627
+ "x2": {"value": "width"},
628
+ "color": {"value": grid.zero.color},
629
+ "xOffset": {"value": 0},
630
+ }
631
+ _neutralize_layer_channels(encoding_v, resolved_chart)
632
+ return MappedLayer(mark=mark, data=rule_data, encoding=encoding_v)
633
+
634
+
635
+ def top_rule_vl_layer(
636
+ resolved_chart: ResolvedChart, data: list[dict[str, Any]]
637
+ ) -> dict[str, Any] | None:
638
+ """Public-API form of the 100%-baseline rule. Mirror of
639
+ ``zero_rule_vl_layer`` for the normalize-stack ceiling.
640
+ """
641
+ layer = _top_rule_layer(resolved_chart)
642
+ if layer is None:
643
+ return None
644
+ out: dict[str, Any] = {"mark": dict(layer.mark), "encoding": dict(layer.encoding)}
645
+ if layer.data is not None:
646
+ out["data"] = dict(layer.data)
647
+ return out
648
+
649
+
650
+ # ── Mark mapping ──────────────────────────────────────────────────────
651
+
652
+ # Chart types whose default mark color comes from ``palette[0]`` as a fill
653
+ # (vs. stroke for line). Arc/pie are intentionally excluded — VL gives
654
+ # mark.fill precedence over encoding.color for arc marks, so palette[0]
655
+ # would freeze every slice to the same colour; per-slice colours flow
656
+ # through encoding.color → ``config.range.category`` instead.
657
+ _PALETTE_FILL_CHART_TYPES: tuple[str, ...] = (
658
+ "bar",
659
+ "area",
660
+ "scatter",
661
+ "circle",
662
+ "point",
663
+ )
664
+ _PALETTE_STROKE_CHART_TYPES: tuple[str, ...] = ("line",)
665
+
666
+
667
+ def _build_mark_style(
668
+ chart_type: str,
669
+ effective: Any,
670
+ orientation: Literal["vertical", "horizontal"],
671
+ has_color_encoding: bool,
672
+ ) -> dict[str, Any]:
673
+ """Build the mark-style dict from the resolved (chart-local-merged) chart-family style.
674
+
675
+ Chart-local mark patches (``style.bar`` / ``style.line`` / ``style.area`` /
676
+ ``style.scatter`` / ``style.arc``) are pre-merged into ``effective.bar`` /
677
+ ``effective.line`` / ``effective.area`` / ``effective.scatter`` / ``effective.arc``
678
+ by build_resolved_style — so this function reads from a single, fully-resolved
679
+ chart-family style.
680
+
681
+ ``orientation`` is forwarded to ``bar_mark_to_vl`` so the band-width
682
+ fraction lands on the correct dimension: ``mark.width`` for vertical bars,
683
+ ``mark.height`` for horizontal bars.
684
+
685
+ ``has_color_encoding`` is True whenever the chart has a color channel
686
+ bound to data (any mode — series, gradient, conditional, literal). In
687
+ that case the palette[0] mark-level fill/stroke default is suppressed
688
+ so VL's color scale (or literal/conditional encoding) wins. Required,
689
+ not defaulted — a default of ``False`` would silently re-shadow the
690
+ encoded color scale for any future caller that forgets the flag.
691
+ """
692
+ from dataface.core.render.chart.vl_field_maps import (
693
+ area_mark_to_vl,
694
+ bar_mark_to_vl,
695
+ line_mark_to_vl,
696
+ scatter_mark_to_vl,
697
+ slice_mark_to_vl,
698
+ )
699
+
700
+ d: dict[str, Any] = {}
701
+
702
+ if effective is not None:
703
+ # Palette[0] default color for single-series charts only. When a
704
+ # color encoding is present (multi-series), the encoded color scale
705
+ # owns mark colour; emitting ``mark.fill = palette[0]`` here would
706
+ # freeze every mark to the same colour despite the scale.
707
+ # arc/pie intentionally never get the palette[0] mark fill: VL
708
+ # gives mark.fill precedence over encoding.color for arc marks
709
+ # (the opposite of bar/line/area), so per-slice colours flow
710
+ # through encoding.color → ``config.range.category`` instead.
711
+ palette0 = effective.palette[0] if effective.palette else None
712
+ # style.color (string) overrides palette[0]; if neither is set, no fill.
713
+ color0 = effective.color if effective.color is not None else palette0
714
+ if color0 is not None and not has_color_encoding:
715
+ if chart_type in _PALETTE_FILL_CHART_TYPES:
716
+ d["fill"] = color0
717
+ elif chart_type in _PALETTE_STROKE_CHART_TYPES:
718
+ d["stroke"] = color0
719
+
720
+ if chart_type == "bar":
721
+ bar_mark = resolve_mark(effective.marks.bar, effective.bar.marks.bar)
722
+ d.update(bar_mark_to_vl(bar_mark, orientation))
723
+ elif chart_type == "histogram":
724
+ bar_mark = resolve_mark(effective.marks.bar, effective.histogram.marks.bar)
725
+ d.update(bar_mark_to_vl(bar_mark, orientation))
726
+ elif chart_type == "line":
727
+ line_marks = effective.line.marks
728
+ line_mark = resolve_mark(effective.marks.line, line_marks.line)
729
+ point_mark = resolve_mark(effective.marks.point, line_marks.point)
730
+ d.update(line_mark_to_vl(line_mark, point_mark))
731
+ elif chart_type == "area":
732
+ area_mark = resolve_mark(effective.marks.area, effective.area.marks.area)
733
+ d.update(area_mark_to_vl(area_mark))
734
+ elif chart_type in ("scatter", "circle", "point"):
735
+ point_mark = resolve_mark(
736
+ effective.marks.point, effective.scatter.marks.point
737
+ )
738
+ d.update(scatter_mark_to_vl(point_mark))
739
+ elif chart_type in ("arc", "pie"):
740
+ pie_marks = effective.pie.marks
741
+ slice_mark = resolve_mark(effective.marks.slice, pie_marks.slice)
742
+ d.update(slice_mark_to_vl(slice_mark))
743
+
744
+ return d
745
+
746
+
747
+ def _map_mark(
748
+ resolved_chart: ResolvedChart,
749
+ custom_registry: CustomChartTypeRegistry | None = None,
750
+ ) -> dict[str, Any]:
751
+ """Map Dataface chart type to a Vega-Lite mark dict."""
752
+ chart_type = resolved_chart.chart_type
753
+ vl_type = CHART_TYPE_MAP.get(chart_type)
754
+ custom_defn = None
755
+
756
+ if vl_type is None and custom_registry:
757
+ custom_defn = custom_registry.get(chart_type)
758
+ if custom_defn:
759
+ vl_type = custom_defn.mark
760
+
761
+ if vl_type is None:
762
+ from dataface.core.errors import DF_RENDER_UNKNOWN_CHART_TYPE
763
+
764
+ raise UnknownChartType.from_code(
765
+ DF_RENDER_UNKNOWN_CHART_TYPE,
766
+ chart_type=chart_type,
767
+ available=", ".join(sorted(CHART_TYPE_MAP.keys())),
768
+ )
769
+
770
+ mark: dict[str, Any] = {"type": vl_type, "tooltip": True}
771
+ if custom_defn and custom_defn.mark_properties:
772
+ mark.update(custom_defn.mark_properties)
773
+
774
+ # When a color encoding drives per-mark colour (any mode — series,
775
+ # gradient, conditional, literal), the palette[0] default fill/stroke
776
+ # must NOT shadow it.
777
+ color_ch = resolved_chart.resolved_channels.get("color")
778
+ has_color_encoding = color_ch is not None
779
+ mark.update(
780
+ _build_mark_style(
781
+ chart_type,
782
+ resolved_chart.resolved_style,
783
+ resolved_chart.orientation,
784
+ has_color_encoding,
785
+ )
786
+ )
787
+
788
+ return mark
789
+
790
+
791
+ # ── Helpers ───────────────────────────────────────────────────────────
792
+
793
+
794
+ def _resolve_orient_auto(
795
+ orient: str | None,
796
+ resolved_chart: ResolvedChart,
797
+ resolved_style: MergedChartsStyle,
798
+ ) -> str | None:
799
+ """Resolve the 'auto' orient sentinel to a concrete VL value at emit time.
800
+
801
+ 'auto' → 'left' when the endpoint-label pane will actually fire
802
+ (the right-edge hconcat pane would collide with a right-side y-axis).
803
+ 'auto' → 'right' otherwise (Dataface convention; VL default is left).
804
+ Non-'auto' values pass through unchanged.
805
+
806
+ Firing condition is delegated to
807
+ ``standard_renderer.endpoint_label_pane_will_fire`` so this resolver
808
+ and the pane-emit code stay in sync. Mirroring the full predicate
809
+ means single-series charts that author ``endpoint_labels.visible:
810
+ true`` keep ``axis_y`` on the right — the pane never renders on
811
+ those, so there's nothing to collide with.
812
+
813
+ Horizontal stacked bars *do* fire the predicate (the top-row rail
814
+ is wrapped in vconcat, not hconcat) so this resolver returns
815
+ ``"left"`` for that case. ``map_y_encoding`` strips the ``"auto"``
816
+ sentinel from the VL x-axis after routing, so horizontal bars never
817
+ end up with a mis-oriented axis from this code path.
818
+ """
819
+ if orient != "auto":
820
+ return orient
821
+ # Local import to avoid a render-layer import cycle: profile.py is
822
+ # imported by standard_renderer.py earlier in module init.
823
+ from dataface.core.render.chart.standard_renderer import (
824
+ endpoint_label_pane_will_fire,
825
+ )
826
+
827
+ return (
828
+ "left"
829
+ if endpoint_label_pane_will_fire(resolved_chart, resolved_style)
830
+ else "right"
831
+ )
832
+
833
+
834
+ def _inject_label_case(axis_dict: dict[str, Any], label_case: str | None) -> None:
835
+ """Apply font.case as a Vega labelExpr on a VL axis dict.
836
+
837
+ 'upper'/'lower' wrap the existing labelExpr if present (so smart cadence
838
+ expressions stack correctly), else inject upper/lower of datum.label.
839
+ 'title'/'sentence' are not applicable to data-bound tick labels — they
840
+ require pre-transforming the query-result domain values, which are not
841
+ available at spec-construction time. 'preserve'/None no-ops.
842
+ Mutates axis_dict in place.
843
+ """
844
+ if label_case not in ("upper", "lower"):
845
+ return
846
+ vl_fn = "upper" if label_case == "upper" else "lower"
847
+ existing = axis_dict.get("labelExpr")
848
+ axis_dict["labelExpr"] = (
849
+ f"{vl_fn}({existing})" if existing else f"{vl_fn}(datum.label)"
850
+ )
851
+
852
+
853
+ def _build_encoding_axis(
854
+ effective: MergedChartsStyle,
855
+ axis_name: Literal["axis_x", "axis_y"],
856
+ channel_type: str,
857
+ chart_type_axis_patch: AxisStylePatch | None = None,
858
+ skip_case_inject: bool = False,
859
+ ) -> dict[str, Any]:
860
+ """Build encoding.{x,y}.axis dict from the merged axis cascade.
861
+
862
+ Chart-local axis patches are pre-merged into ``effective.axis_x`` /
863
+ ``effective.axis_y`` / ``effective.axis_quantitative`` / ``effective.axis_band``
864
+ by build_resolved_style — so the renderer reads from a single, fully-resolved
865
+ source. ``resolved_axis_style`` handles the channel-type dispatch
866
+ (axis_quantitative for quantitative, axis_band for ordinal/nominal) and
867
+ Layer 3.5 chart-type-specific overlay; the result is mapped to VL via
868
+ ``axis_to_vl``. The zero baseline is drawn by the dedicated zero-rule layer
869
+ (``_zero_rule_layer``) on top of the chart fills — so the grid does not
870
+ encode a zero highlight here.
871
+
872
+ Case transforms on axis tick labels:
873
+ - 'upper'/'lower': injected here via _inject_label_case for all call sites
874
+ except map_x_encoding (skip_case_inject=True), where the smart cadence
875
+ labelExpr block runs *after* _build_encoding_axis and must be wrapped.
876
+ map_x_encoding calls _inject_label_case itself at its tail.
877
+ - 'title'/'sentence': NOT applied to data-bound tick labels. These require
878
+ pre-transforming the actual domain values (strings from query results)
879
+ which are not available at spec-construction time. They apply only to
880
+ static text fields (chart title, axis title, KPI label, table header).
881
+ A 'preserve' label case (the default) emits no labelExpr.
882
+ """
883
+ from dataface.core.render.chart.vl_field_maps import axis_to_vl
884
+
885
+ merged = resolved_axis_style(
886
+ effective, axis_name, channel_type, chart_type_axis_patch
887
+ )
888
+ result = {k: v for k, v in axis_to_vl(merged).items() if v is not None}
889
+ if "format" in result:
890
+ result["format"] = resolve_format(result["format"], effective.formats)
891
+
892
+ if not skip_case_inject:
893
+ _inject_label_case(result, merged.label.font.case)
894
+
895
+ return result
896
+
897
+
898
+ def _emit_band_size(scale: Any, out: dict[str, Any]) -> None:
899
+ """Write minBandSize/maxBandSize into *out* from a scale's band sub-object."""
900
+ if scale is None or scale.band is None:
901
+ return
902
+ if scale.band.min_size is not None:
903
+ out["minBandSize"] = scale.band.min_size
904
+ if scale.band.max_size is not None:
905
+ out["maxBandSize"] = scale.band.max_size
906
+
907
+
908
+ def _build_channel_scale(
909
+ effective: MergedChartsStyle,
910
+ axis_name: Literal["axis_x", "axis_y"],
911
+ channel_type: str,
912
+ chart_type_axis_patch: AxisStylePatch | None = None,
913
+ ) -> dict[str, Any]:
914
+ """Build a VL ``encoding.<channel>.scale`` dict from the merged axis cascade.
915
+
916
+ The renderer reads scale state in two layers:
917
+ 1. Chart-level global scale (``effective.scale``)
918
+ 2. The merged per-axis scale from ``resolved_axis_style`` — which already
919
+ applies theme cascade + chart-type Layer 3.5 + chart-local overrides
920
+ in canonical order, so per-axis (most specific) wins.
921
+ """
922
+ from dataface.core.render.chart.vl_field_maps import (
923
+ SCALE_ENCODING_FIELD_MAP,
924
+ SCALE_FIELD_MAP,
925
+ )
926
+
927
+ out: dict[str, Any] = {}
928
+ # Layer 1: chart-level global scale (lower precedence — applies to all axes).
929
+ if effective.scale is not None:
930
+ out.update(map_fields(effective.scale, SCALE_FIELD_MAP))
931
+ out.update(map_fields(effective.scale, SCALE_ENCODING_FIELD_MAP))
932
+ _emit_band_size(effective.scale, out)
933
+ # Layer 2: per-axis scale via the canonical cascade reader.
934
+ merged_axis = resolved_axis_style(
935
+ effective, axis_name, channel_type, chart_type_axis_patch
936
+ )
937
+ if merged_axis.scale is not None:
938
+ out.update(map_fields(merged_axis.scale, SCALE_FIELD_MAP))
939
+ out.update(map_fields(merged_axis.scale, SCALE_ENCODING_FIELD_MAP))
940
+ _emit_band_size(merged_axis.scale, out)
941
+ return out
942
+
943
+
944
+ # ── Channel encoding mapping ─────────────────────────────────────────
945
+
946
+
947
+ @dataclass(frozen=True)
948
+ class _AxisRouting:
949
+ """Upstream channel↔cascade routing for a single chart.
950
+
951
+ Horizontal bar swaps field assignment and axis-cascade names so that
952
+ mapping helpers build the right VL encoding from the start — no
953
+ post-hoc swap or cleanup needed.
954
+
955
+ ``vl_x_*`` describe the channel that lands in ``encoding["x"]``.
956
+ ``vl_y_*`` describe the channel that lands in ``encoding["y"]``.
957
+ ``vl_x_label_src`` / ``vl_y_label_src`` control which authored label
958
+ (x_label or y_label) titles each VL channel. Under dataface semantics
959
+ axis_x = categorical, axis_y = measure regardless of orientation, so
960
+ for horizontal bar the labels travel with chart.x / chart.y rather than
961
+ with VL x / VL y.
962
+ ``vl_y_infer_type`` is True when the VL-y field should have its type
963
+ inferred from data (nominal/ordinal) instead of defaulting to quantitative.
964
+ """
965
+
966
+ vl_x_field: str | None
967
+ vl_y_field: str | None
968
+ vl_x_axis_cascade: Literal["axis_x", "axis_y"]
969
+ vl_y_axis_cascade: Literal["axis_x", "axis_y"]
970
+ vl_x_label_src: Literal["x_label", "y_label"]
971
+ vl_y_label_src: Literal["x_label", "y_label"]
972
+ vl_y_infer_type: bool
973
+
974
+
975
+ def _build_axis_routing(resolved_chart: ResolvedChart) -> _AxisRouting:
976
+ """Build channel↔cascade routing for the given chart.
977
+
978
+ Horizontal bar routes the measure field to VL x (using the axis_y
979
+ cascade, since axis_y = measure axis under dataface semantics) and
980
+ the categorical field to VL y (using the axis_x cascade, since
981
+ axis_x = categorical axis). All other charts use the identity routing.
982
+ """
983
+ if (
984
+ resolved_chart.orientation == "horizontal"
985
+ and resolved_chart.chart_type == "bar"
986
+ ):
987
+ return _AxisRouting(
988
+ vl_x_field=resolved_chart.y if isinstance(resolved_chart.y, str) else None,
989
+ vl_y_field=resolved_chart.x,
990
+ vl_x_axis_cascade="axis_y",
991
+ vl_y_axis_cascade="axis_x",
992
+ vl_x_label_src="y_label",
993
+ vl_y_label_src="x_label",
994
+ vl_y_infer_type=True,
995
+ )
996
+ return _AxisRouting(
997
+ vl_x_field=resolved_chart.x,
998
+ vl_y_field=resolved_chart.y if not isinstance(resolved_chart.y, list) else None,
999
+ vl_x_axis_cascade="axis_x",
1000
+ vl_y_axis_cascade="axis_y",
1001
+ vl_x_label_src="x_label",
1002
+ vl_y_label_src="y_label",
1003
+ vl_y_infer_type=(resolved_chart.chart_type == "scatter"),
1004
+ )
1005
+
1006
+
1007
+ def map_x_encoding(
1008
+ resolved_chart: ResolvedChart,
1009
+ data: list[dict[str, Any]],
1010
+ *,
1011
+ routing: _AxisRouting | None = None,
1012
+ ) -> dict[str, Any] | None:
1013
+ """Map the x channel from Dataface to Vega-Lite encoding."""
1014
+ x_field = routing.vl_x_field if routing is not None else resolved_chart.x
1015
+ if not x_field:
1016
+ return None
1017
+
1018
+ axis_cascade: Literal["axis_x", "axis_y"] = (
1019
+ routing.vl_x_axis_cascade if routing is not None else "axis_x"
1020
+ )
1021
+ columns = set(data[0].keys()) if data else set()
1022
+ effective = resolved_chart.resolved_style
1023
+ # Data-inferred type: "temporal" for date/datetime values, else "nominal" etc.
1024
+ x_type_from_data = (
1025
+ infer_vega_type_from_data(data, x_field) if x_field in columns else "nominal"
1026
+ )
1027
+ x_type = x_type_from_data
1028
+
1029
+ # Probe authored time_unit + axis type from the full cascade.
1030
+ # Use the data-inferred channel type for the initial probe; axis_overrides_global
1031
+ # and axis_overrides_x surface authored time_unit regardless of channel type.
1032
+ _initial_channel_type = x_type
1033
+ _ct_attr_x_for_tu = (
1034
+ "arc" if resolved_chart.chart_type == "pie" else resolved_chart.chart_type
1035
+ )
1036
+ _ct_style_x_for_tu = getattr(effective, _ct_attr_x_for_tu, None)
1037
+ _ct_axis_x_for_tu = (
1038
+ getattr(_ct_style_x_for_tu, axis_cascade, None)
1039
+ if _ct_style_x_for_tu is not None
1040
+ else None
1041
+ )
1042
+ _merged_x = resolved_axis_style(
1043
+ effective, axis_cascade, _initial_channel_type, _ct_axis_x_for_tu
1044
+ )
1045
+ authored_tu = _merged_x.time_unit
1046
+ authored_axis_type = _merged_x.type # None / "auto" / "ordinal" / "temporal"
1047
+
1048
+ # For explicitly authored time_unit (non-auto/none) on non-date data:
1049
+ # validate the data is parseable as dates so a bad authored time_unit fails
1050
+ # loudly rather than silently producing a nominal-typed axis.
1051
+ if authored_tu and authored_tu not in ("auto", "none") and x_type != "temporal":
1052
+ values = [row.get(x_field) for row in data if x_field in row]
1053
+ if values:
1054
+ detect_time_unit(values) # raises ValueError on ≥10% unparseable
1055
+
1056
+ # Pre-resolve time_unit for the scale-type decision below.
1057
+ # Resolve regardless of current x_type so we can decide ordinal vs temporal.
1058
+ if authored_tu == "none":
1059
+ time_unit: str | None = None
1060
+ elif authored_tu and authored_tu not in ("auto",):
1061
+ time_unit = authored_tu
1062
+ elif x_type == "temporal":
1063
+ time_unit = detect_time_unit(
1064
+ [row.get(x_field) for row in data if x_field in row]
1065
+ )
1066
+ else:
1067
+ time_unit = None
1068
+
1069
+ # Scale-type decision: bucketed-calendar grains default to ordinal.
1070
+ # Author's axis_x.type always wins over the default.
1071
+ if authored_axis_type == "temporal":
1072
+ # Escape hatch: force temporal even for bucketed data.
1073
+ if x_type != "temporal":
1074
+ x_type = "temporal"
1075
+ elif authored_axis_type == "ordinal":
1076
+ x_type = "ordinal"
1077
+ elif time_unit and time_unit in BUCKETED_CALENDAR_UNITS:
1078
+ # Auto/explicit bucketed-calendar grain → ordinal by default.
1079
+ x_type = "ordinal"
1080
+ elif authored_tu and authored_tu not in ("auto", "none") and x_type != "temporal":
1081
+ # Explicit time-part (monthofyear, dayofweek, etc.) on non-date data
1082
+ # → force temporal (unchanged behaviour for time-part units).
1083
+ x_type = "temporal"
1084
+
1085
+ underlying_temporal = x_type == "temporal"
1086
+
1087
+ authored_label = (
1088
+ resolved_chart.x_label
1089
+ if routing is None or routing.vl_x_label_src == "x_label"
1090
+ else resolved_chart.y_label
1091
+ )
1092
+ encoding: dict[str, Any] = {
1093
+ "field": x_field,
1094
+ "type": x_type,
1095
+ "title": (
1096
+ authored_label
1097
+ if authored_label
1098
+ else format_display_text(
1099
+ x_field, from_slug=True, font=effective.axis_x.title.font
1100
+ )
1101
+ ),
1102
+ "axis": {},
1103
+ }
1104
+ # Unified axis emit: chart-local axis layer is pre-merged into effective by
1105
+ # build_resolved_style; the cascade helper handles type-conditional dispatch
1106
+ # and chart-type-specific Layer 3.5.
1107
+ _ct_attr_x = (
1108
+ "arc" if resolved_chart.chart_type == "pie" else resolved_chart.chart_type
1109
+ )
1110
+ _ct_style_x = getattr(effective, _ct_attr_x, None)
1111
+ _ct_axis_x = (
1112
+ getattr(_ct_style_x, axis_cascade, None) if _ct_style_x is not None else None
1113
+ )
1114
+ encoding["axis"].update(
1115
+ _build_encoding_axis(
1116
+ effective, axis_cascade, x_type, _ct_axis_x, skip_case_inject=True
1117
+ )
1118
+ )
1119
+ # axis_y carries orient="auto" for the y-axis side. That sentinel is only
1120
+ # meaningful for VL y; strip it when axis_y cascade drives VL x.
1121
+ if axis_cascade == "axis_y" and encoding["axis"].get("orient") == "auto":
1122
+ encoding["axis"].pop("orient")
1123
+
1124
+ # When a d3-time-format string (e.g. "%b %Y") is applied to a non-temporal
1125
+ # axis, Vega defaults to d3-format (number formatting) which rejects
1126
+ # time-format patterns. ``formatType: "time"`` would route through
1127
+ # d3-time-format under the *runtime's* local TZ (D-003a leak). Emit a UTC
1128
+ # labelExpr that is TZ-independent by construction.
1129
+ axis_fmt = encoding["axis"].get("format")
1130
+ if isinstance(axis_fmt, str) and x_type != "temporal" and _is_time_format(axis_fmt):
1131
+ if "labelExpr" not in encoding["axis"]:
1132
+ encoding["axis"]["labelExpr"] = _utc_time_label_expr(axis_fmt)
1133
+ encoding["axis"].pop("format", None)
1134
+
1135
+ x_scale = _build_channel_scale(effective, axis_cascade, x_type, _ct_axis_x)
1136
+ if x_type == "temporal":
1137
+ x_scale = {"type": "utc", **x_scale}
1138
+
1139
+ # For horizontal bar, VL x is the measure axis. Apply chart.zero and domainMax
1140
+ # here, symmetrically with map_y_encoding's quantitative-scale blocks.
1141
+ if (
1142
+ routing is not None
1143
+ and routing.vl_x_axis_cascade == "axis_y"
1144
+ and x_type == "quantitative"
1145
+ and resolved_chart.zero is not None
1146
+ ):
1147
+ x_scale["zero"] = resolved_chart.zero
1148
+
1149
+ if (
1150
+ routing is not None
1151
+ and routing.vl_x_axis_cascade == "axis_y"
1152
+ and resolved_chart.chart_type == "bar"
1153
+ and x_type == "quantitative"
1154
+ and resolved_chart.stack not in (False, "normalize")
1155
+ and not is_grouped_bar(resolved_chart)
1156
+ and isinstance(resolved_chart.x, str)
1157
+ and "domain" not in x_scale
1158
+ ):
1159
+ stacked_max = stacked_bar_totals_max(data, resolved_chart.x, x_field)
1160
+ if stacked_max is not None:
1161
+ x_scale["domainMax"] = stacked_max
1162
+
1163
+ if x_scale:
1164
+ encoding["scale"] = x_scale
1165
+
1166
+ if (
1167
+ resolved_chart.chart_type in {"bar", "line", "area", "layered"}
1168
+ and x_type != "quantitative"
1169
+ ):
1170
+ encoding["sort"] = None
1171
+ if underlying_temporal:
1172
+ # Temporal path (including escape-hatch): emit timeUnit + smart labelExpr.
1173
+ if time_unit is not None:
1174
+ encoding["timeUnit"] = vl_time_unit(time_unit)
1175
+ if "labelExpr" not in encoding["axis"]:
1176
+ authored_label_tu = resolved_axis_style(
1177
+ effective, axis_cascade, x_type, _ct_axis_x
1178
+ ).label.time_unit
1179
+ label_time_unit = resolve_label_time_unit(
1180
+ time_unit, authored_label_tu
1181
+ )
1182
+ smart_expr = default_label_expr_for(time_unit, label_time_unit)
1183
+ if smart_expr is not None:
1184
+ encoding["axis"]["labelExpr"] = smart_expr
1185
+ elif x_type == "ordinal" and time_unit and time_unit in BUCKETED_CALENDAR_UNITS:
1186
+ # Ordinal bucketed-time path: no timeUnit, no labelExpr.
1187
+ # Emit axis.values (precomputed sorted tick list) and a smart
1188
+ # default format for date-like input columns.
1189
+ authored_label_tu = resolved_axis_style(
1190
+ effective, axis_cascade, x_type, _ct_axis_x
1191
+ ).label.time_unit
1192
+ label_tu = resolve_label_time_unit(time_unit, authored_label_tu)
1193
+ tick_values = ordinal_axis_values(data, x_field, time_unit, label_tu)
1194
+ if tick_values is not None and "values" not in encoding["axis"]:
1195
+ encoding["axis"]["values"] = tick_values
1196
+ # Reuse the temporal-path smart labelExpr builders in ordinal
1197
+ # mode for date-like (temporal-inferred) inputs when the author
1198
+ # has not set an explicit format string. ``toDate(datum.value)``
1199
+ # parses the ISO date string back to a UTC instant; ``utcFormat``
1200
+ # emits the d3-time-format string without TZ drift; the
1201
+ # cadence-aware gate stamps the year only on label-period
1202
+ # openers (under January for yearmonth, Q1 for yearquarter, W1
1203
+ # of January for yearweek, etc.) — the same multi-line
1204
+ # convention the temporal path produced before #2400.
1205
+ # ``formatType: "time"`` would silently drop every label on a
1206
+ # string-domain ordinal scale.
1207
+ if (
1208
+ x_type_from_data == "temporal"
1209
+ and not axis_fmt
1210
+ and "labelExpr" not in encoding["axis"]
1211
+ ):
1212
+ smart_expr = default_label_expr_for(time_unit, label_tu)
1213
+ if smart_expr is not None:
1214
+ encoding["axis"]["labelExpr"] = smart_expr
1215
+
1216
+ # Inject upper/lower case after all smart temporal labelExpr blocks so case
1217
+ # wraps the cadence expression (upper(<smart_expr>)) rather than replacing
1218
+ # it with the naive upper(datum.label) form that the temporal block would
1219
+ # then silently skip. _build_encoding_axis skips injection (skip_case_inject=True)
1220
+ # so this is the only case-injection site for the x-axis path.
1221
+ _label_case = resolved_axis_style(
1222
+ effective, axis_cascade, x_type, _ct_axis_x
1223
+ ).label.font.case
1224
+ _inject_label_case(encoding["axis"], _label_case)
1225
+
1226
+ return encoding
1227
+
1228
+
1229
+ def map_y_encoding(
1230
+ resolved_chart: ResolvedChart,
1231
+ data: list[dict[str, Any]],
1232
+ *,
1233
+ y_field: str | None = None,
1234
+ routing: _AxisRouting | None = None,
1235
+ skip_tick_values: bool = False,
1236
+ ) -> dict[str, Any] | None:
1237
+ """Map the y channel from Dataface to Vega-Lite encoding."""
1238
+ assert (
1239
+ routing is None or y_field is None
1240
+ ), "routing and y_field are mutually exclusive"
1241
+ resolved_y_field: str | list[str] | None
1242
+ if routing is not None:
1243
+ resolved_y_field = routing.vl_y_field
1244
+ elif y_field is not None:
1245
+ resolved_y_field = y_field
1246
+ else:
1247
+ resolved_y_field = resolved_chart.y
1248
+ if not resolved_y_field or isinstance(resolved_y_field, list):
1249
+ return None
1250
+
1251
+ axis_cascade: Literal["axis_x", "axis_y"] = (
1252
+ routing.vl_y_axis_cascade if routing is not None else "axis_y"
1253
+ )
1254
+ columns = set(data[0].keys()) if data else set()
1255
+ effective = resolved_chart.resolved_style
1256
+ user_format = (
1257
+ resolve_format(resolved_chart.format, effective.formats)
1258
+ if resolved_chart.format
1259
+ else ""
1260
+ )
1261
+
1262
+ infer_from_data = resolved_chart.chart_type == "scatter" or (
1263
+ routing is not None and routing.vl_y_infer_type
1264
+ )
1265
+ y_type = "quantitative"
1266
+ if infer_from_data:
1267
+ y_type = (
1268
+ infer_vega_type_from_data(data, resolved_y_field)
1269
+ if resolved_y_field in columns
1270
+ else "nominal"
1271
+ )
1272
+
1273
+ y_axis: dict[str, Any] = {}
1274
+ # Unified axis emit: chart-local axis layer is pre-merged into effective by
1275
+ # build_resolved_style; the helper handles type-conditional dispatch.
1276
+ _ct_attr_y = (
1277
+ "arc" if resolved_chart.chart_type == "pie" else resolved_chart.chart_type
1278
+ )
1279
+ _ct_style_y = getattr(effective, _ct_attr_y, None)
1280
+ _ct_axis_y = (
1281
+ getattr(_ct_style_y, axis_cascade, None) if _ct_style_y is not None else None
1282
+ )
1283
+ y_axis.update(_build_encoding_axis(effective, axis_cascade, y_type, _ct_axis_y))
1284
+ # Resolve "auto" orient sentinel: right by default, left when the chart
1285
+ # family emits right-edge series labels that would collide with the axis.
1286
+ # axis_y carries orient="auto"; axis_x does not — only check when needed.
1287
+ if axis_cascade == "axis_y" and y_axis.get("orient") == "auto":
1288
+ y_axis["orient"] = _resolve_orient_auto("auto", resolved_chart, effective)
1289
+ if user_format and y_type == "quantitative":
1290
+ y_axis["format"] = user_format
1291
+
1292
+ # Enforce tick density for quantitative y-axes when ticks.count is set.
1293
+ # VL's tickCount is advisory (d3 rounds to "nice" steps and ignores the
1294
+ # target); emit explicit tickValues so VL respects the editorial intent.
1295
+ # Only fires when data is available and the author hasn't pinned values.
1296
+ # Skip for normalize-stacked bars: _apply_stack_encoding sets quartile
1297
+ # ticks (0, 0.25, 0.5, 0.75, 1.0) after map_y_encoding; we must not
1298
+ # pre-empt them since the raw data values are raw counts, not fractions.
1299
+ # Skip for layered multi-metric charts: each layer calls map_y_encoding
1300
+ # with its own metric range; per-layer axis.values conflict on the shared
1301
+ # y-scale, so callers pass skip_tick_values=True for layered contexts.
1302
+ _is_normalize_stack = resolved_chart.stack == "normalize"
1303
+ # Stacked area (stack: "zero") and streamgraph (stack: "center") have
1304
+ # complex y-domains VL must compute from stacked totals. Pinning domainMax
1305
+ # to the individual-row maximum clips marks above that value → chart vanishes.
1306
+ _is_stacked_area = (
1307
+ resolved_chart.chart_type == "area"
1308
+ and resolved_chart.stack not in (False, None)
1309
+ )
1310
+ # True only when the pipeline explicitly resolved zero:true — meaning the
1311
+ # scale will extend to 0 with data living above it, creating a potential
1312
+ # blank gap below the first gridline that domainMin must close.
1313
+ _chart_uses_zero = resolved_chart.zero is True
1314
+ _computed_tick_vals: list[float] | None = None
1315
+ if (
1316
+ y_type == "quantitative"
1317
+ and "values" not in y_axis
1318
+ and data
1319
+ and not _is_normalize_stack
1320
+ and not _is_stacked_area
1321
+ and not skip_tick_values
1322
+ ):
1323
+ _merged_y = resolved_axis_style(effective, axis_cascade, y_type, _ct_axis_y)
1324
+ tick_count = _merged_y.ticks.count
1325
+ if tick_count is not None:
1326
+ y_floats: list[float] = [
1327
+ float(row[resolved_y_field])
1328
+ for row in data
1329
+ if isinstance(row.get(resolved_y_field), (int, float))
1330
+ ]
1331
+ if y_floats:
1332
+ # For stacked bars the per-row values are individual category
1333
+ # contributions; the visible axis spans the stacked column
1334
+ # totals. Derive domain_max from stacked totals when applicable
1335
+ # (same condition as the domainMax scale pin below).
1336
+ _x_field = resolved_chart.x
1337
+ _is_stacked_bar = (
1338
+ resolved_chart.chart_type == "bar"
1339
+ and resolved_chart.stack not in (False, "normalize", None)
1340
+ and not is_grouped_bar(resolved_chart)
1341
+ and isinstance(_x_field, str)
1342
+ )
1343
+ if _is_stacked_bar and isinstance(_x_field, str):
1344
+ _stacked_max = stacked_bar_totals_max(
1345
+ data, _x_field, resolved_y_field
1346
+ )
1347
+ domain_max = (
1348
+ _stacked_max if _stacked_max is not None else max(y_floats)
1349
+ )
1350
+ else:
1351
+ domain_max = max(y_floats)
1352
+ domain_min = (
1353
+ min(0.0, min(y_floats)) if _chart_uses_zero else min(y_floats)
1354
+ )
1355
+ _computed_tick_vals = nice_tick_values(
1356
+ domain_min, domain_max, tick_count
1357
+ )
1358
+ y_axis["values"] = _computed_tick_vals
1359
+
1360
+ # Mirror the x-axis guard: when a d3-time-format string is supplied for a
1361
+ # non-temporal y-axis, Vega's default d3-format rejects the time directive
1362
+ # and crashes with a scene-graph TypeError. ``formatType: "time"`` would
1363
+ # route through d3-time-format under the *runtime's* local TZ (D-003a leak).
1364
+ # Emit a UTC labelExpr that is TZ-independent by construction.
1365
+ axis_y_fmt = y_axis.get("format")
1366
+ if (
1367
+ isinstance(axis_y_fmt, str)
1368
+ and y_type != "temporal"
1369
+ and _is_time_format(axis_y_fmt)
1370
+ ):
1371
+ if "labelExpr" not in y_axis:
1372
+ y_axis["labelExpr"] = _utc_time_label_expr(axis_y_fmt)
1373
+ y_axis.pop("format", None)
1374
+
1375
+ authored_y_label = (
1376
+ resolved_chart.y_label
1377
+ if routing is None or routing.vl_y_label_src == "y_label"
1378
+ else resolved_chart.x_label
1379
+ )
1380
+ encoding: dict[str, Any] = {
1381
+ "field": resolved_y_field,
1382
+ "type": y_type,
1383
+ "title": (
1384
+ authored_y_label
1385
+ if authored_y_label and y_field is None
1386
+ else format_display_text(
1387
+ resolved_y_field, from_slug=True, font=effective.axis_y.title.font
1388
+ )
1389
+ ),
1390
+ "axis": y_axis,
1391
+ }
1392
+ if user_format and y_type == "quantitative":
1393
+ encoding["format"] = user_format
1394
+
1395
+ y_scale = _build_channel_scale(effective, axis_cascade, y_type, _ct_axis_y)
1396
+ if resolved_chart.zero is not None and y_type == "quantitative":
1397
+ y_scale["zero"] = resolved_chart.zero
1398
+
1399
+ # Pin domainMax for stacked vertical bar charts so the scale spans the full
1400
+ # stacked extent. Horizontal bar's VL x (measure) handles its own domainMax
1401
+ # in map_x_encoding. Grouped bars skip this — each bar is independent.
1402
+ if (
1403
+ resolved_chart.chart_type == "bar"
1404
+ and y_type == "quantitative"
1405
+ and resolved_chart.stack not in (False, "normalize")
1406
+ and not is_grouped_bar(resolved_chart)
1407
+ and isinstance(resolved_chart.x, str)
1408
+ ):
1409
+ if "domain" not in y_scale:
1410
+ stacked_max = stacked_bar_totals_max(
1411
+ data, resolved_chart.x, resolved_y_field
1412
+ )
1413
+ if stacked_max is not None:
1414
+ y_scale["domainMax"] = stacked_max
1415
+
1416
+ # Pin domainMin to the first tick for zero-baseline charts (bar, area) so
1417
+ # VL's scale.zero=true cannot push the domain below the lowest gridline,
1418
+ # leaving blank space (e.g. $0–$80K gap when ticks start at $80K on a
1419
+ # $97K–$168K dataset). Never pin domainMax — that boxes the top of the
1420
+ # chart with a gridline at the edge, which looks wrong for all mark types.
1421
+ # Author-set domain/domainMin takes precedence.
1422
+ if _computed_tick_vals is not None and _chart_uses_zero and "domain" not in y_scale:
1423
+ if "domainMin" not in y_scale:
1424
+ y_scale["domainMin"] = _computed_tick_vals[0]
1425
+
1426
+ if y_scale:
1427
+ encoding["scale"] = y_scale
1428
+
1429
+ return encoding
1430
+
1431
+
1432
+ def _build_encoding_legend(effective: MergedChartsStyle) -> dict[str, Any] | None:
1433
+ """Build encoding.{color,size}.legend dict from the merged resolved legend.
1434
+
1435
+ Chart-local legend patches are pre-merged into ``effective.legend`` by
1436
+ build_resolved_style, so the renderer reads from a single resolved value.
1437
+ ``effective.legend.disable=True`` returns None (VL emits ``legend = null``);
1438
+ any other state returns a dict.
1439
+ """
1440
+ from dataface.core.render.chart.vl_field_maps import legend_to_vl
1441
+
1442
+ if effective.legend.disable is True:
1443
+ return None
1444
+ d: dict[str, Any] = legend_to_vl(effective.legend) or {}
1445
+ return d if d else None
1446
+
1447
+
1448
+ def map_other_encoding(
1449
+ channel_name: str,
1450
+ resolved_chart: ResolvedChart,
1451
+ data: list[dict[str, Any]],
1452
+ ) -> dict[str, Any] | None:
1453
+ """Map color/size/shape channels from Dataface to Vega-Lite encoding."""
1454
+ effective = resolved_chart.resolved_style
1455
+
1456
+ if channel_name == "color":
1457
+ color_ch = resolved_chart.resolved_channels.get("color")
1458
+ if color_ch is None:
1459
+ return None
1460
+ if color_ch.mode == "series" and color_ch.data_field:
1461
+ columns = set(data[0].keys()) if data else set()
1462
+ inferred_type = (
1463
+ infer_vega_type_from_data(data, color_ch.data_field)
1464
+ if color_ch.data_field in columns
1465
+ else "nominal"
1466
+ )
1467
+ enc: dict[str, Any] = {"field": color_ch.data_field, "type": inferred_type}
1468
+ else:
1469
+ vl_enc = _resolved_channel_to_vl(color_ch)
1470
+ if vl_enc is None:
1471
+ return None
1472
+ enc = vl_enc
1473
+
1474
+ # Literal-mode encodings (``{value: "#hex"}``) are constants — Vega-Lite
1475
+ # ignores the value if a legend object is attached, so the bars/lines
1476
+ # silently revert to the theme default. Skip the legend injection.
1477
+ if color_ch.mode == "literal":
1478
+ return enc
1479
+
1480
+ # Set title so vl-convert emits the titled key in aria-labels — without
1481
+ # it, channels collapse to the raw lowercase field name (e.g. "kind: task"
1482
+ # instead of "Kind: task").
1483
+ if enc.get("field"):
1484
+ enc["title"] = format_display_text(
1485
+ enc["field"], from_slug=True, font=effective.legend.title.font
1486
+ )
1487
+
1488
+ # Inject unified legend at encoding.color.legend
1489
+ legend = _build_encoding_legend(effective)
1490
+ enc["legend"] = legend # None → null disables, dict → legend config
1491
+ return enc
1492
+
1493
+ other_field = getattr(resolved_chart, channel_name)
1494
+ if not other_field or isinstance(other_field, list):
1495
+ return None
1496
+
1497
+ columns = set(data[0].keys()) if data else set()
1498
+ default_type = "quantitative" if channel_name == "size" else "nominal"
1499
+ inferred_type = (
1500
+ infer_vega_type_from_data(data, other_field)
1501
+ if other_field in columns
1502
+ else default_type
1503
+ )
1504
+ enc = {
1505
+ "field": other_field,
1506
+ "type": inferred_type,
1507
+ "title": format_display_text(
1508
+ other_field, from_slug=True, font=effective.legend.title.font
1509
+ ),
1510
+ }
1511
+
1512
+ # Inject unified legend for size/shape channels too
1513
+ if channel_name in ("size", "shape"):
1514
+ legend = _build_encoding_legend(effective)
1515
+ enc["legend"] = legend
1516
+ return enc
1517
+
1518
+
1519
+ def _cf_rules_to_vl_condition(
1520
+ rules: Iterable[ConditionalRule],
1521
+ field_ref: str,
1522
+ ) -> dict[str, Any] | None:
1523
+ """Build a VL conditional color encoding from CF rules for a single field.
1524
+
1525
+ Only rules with a ``background`` output are encoded; font-only rules
1526
+ are silently skipped (marks have no text to style).
1527
+ Returns None when no background conditions exist.
1528
+ """
1529
+ conditions = [
1530
+ {"test": _rule_to_vl_test(rule, field_ref), "value": rule.background}
1531
+ for rule in rules
1532
+ if rule.background is not None
1533
+ ]
1534
+ if not conditions:
1535
+ return None
1536
+ # value: null is the VL no-match fallback — leaves the mark un-encoded.
1537
+ return {"condition": conditions, "value": None}
1538
+
1539
+
1540
+ def _resolved_channel_to_vl(ch: ResolvedStyleChannel) -> dict[str, Any] | None:
1541
+ """Convert a ResolvedStyleChannel to a Vega-Lite encoding dict."""
1542
+ if ch.mode == "literal":
1543
+ return {"value": ch.literal_value}
1544
+
1545
+ if ch.mode == "series":
1546
+ return {"field": ch.data_field, "type": "nominal"}
1547
+
1548
+ if ch.mode == "gradient":
1549
+ assert ch.scale is not None # gradient mode always has a scale
1550
+ scale = ch.scale
1551
+ vl_scale: dict[str, Any] = {"range": list(scale.palette)}
1552
+ if scale.min is not None and scale.max is not None:
1553
+ vl_scale["domain"] = [scale.min, scale.max]
1554
+ elif scale.min is not None:
1555
+ vl_scale["domainMin"] = scale.min
1556
+ elif scale.max is not None:
1557
+ vl_scale["domainMax"] = scale.max
1558
+ return {"field": ch.data_field, "type": "quantitative", "scale": vl_scale}
1559
+
1560
+ if ch.mode == "conditional":
1561
+ field_ref = f"datum[{json.dumps(ch.data_field)}]"
1562
+ return _cf_rules_to_vl_condition(ch.rules, field_ref)
1563
+
1564
+ raise ValueError(f"Unreachable: unknown channel mode {ch.mode!r}")
1565
+
1566
+
1567
+ # ── Structural transforms ────────────────────────────────────────────
1568
+
1569
+
1570
+ _SORT_ORDER_VL = {"asc": "ascending", "desc": "descending"}
1571
+
1572
+
1573
+ def chart_has_active_dashes(resolved_chart: ResolvedChart) -> bool:
1574
+ """True when the chart should emit a ``strokeDash`` encoding.
1575
+
1576
+ Shared predicate used by ``_apply_dash_encoding`` (emits the encoding) and
1577
+ the zero/top rule-layer constructors (must null out inheritance only when
1578
+ the encoding is actually present). Gating these on the same predicate
1579
+ keeps existing themes-without-dashes producing byte-identical SVG.
1580
+
1581
+ Fires only when (a) the chart mark is line-family (line/area), (b) the
1582
+ merged theme has ``dashes`` set, and (c) the chart's resolved color
1583
+ channel is a categorical series field. The render layer reads None on
1584
+ ``MergedChartsStyle.dashes`` as "skip emission" — see the cascade-managed-
1585
+ sentinel comment on the field.
1586
+ """
1587
+ if resolved_chart.chart_type not in {"line", "area"}:
1588
+ return False
1589
+ if resolved_chart.resolved_style.dashes is None:
1590
+ return False
1591
+ color_ch = resolved_chart.resolved_channels.get("color")
1592
+ if color_ch is None:
1593
+ return False
1594
+ return color_ch.mode == "series" and bool(color_ch.data_field)
1595
+
1596
+
1597
+ # Per-channel registries for _neutralize_layer_channels.
1598
+ # Pattern fills will extend these two dicts; everything else stays the same.
1599
+ _CHANNEL_PREDICATES: dict[str, Callable[[ResolvedChart], bool]] = {
1600
+ "strokeDash": chart_has_active_dashes,
1601
+ }
1602
+ # The "channel is off" VL value for each registered channel.
1603
+ # strokeDash: [] → VL interprets an empty array as a solid stroke.
1604
+ _NEUTRAL_VALUES: dict[str, Any] = {
1605
+ "strokeDash": [],
1606
+ }
1607
+
1608
+
1609
+ def _neutralize_layer_channels(
1610
+ encoding: dict[str, Any],
1611
+ resolved_chart: ResolvedChart,
1612
+ channels: list[str] | None = None,
1613
+ ) -> None:
1614
+ """Stamp neutral VL values onto ``encoding`` for channels that are active
1615
+ at the chart level but whose data is not present in this layer.
1616
+
1617
+ Rule layers and other synthetic-data layers call this to prevent VL from
1618
+ inheriting a top-level ``strokeDash`` (or future ``patternFill``) binding
1619
+ onto a layer whose data row has no series field — which would pollute the
1620
+ scale domain with ``undefined`` and stamp spurious attributes on the SVG.
1621
+
1622
+ Mutates ``encoding`` in place. When ``channels`` is None, defaults to all
1623
+ registered channels (``list(_CHANNEL_PREDICATES.keys())``). Pass an
1624
+ explicit ``channels=[]`` to suppress all neutralization.
1625
+ """
1626
+ active = list(_CHANNEL_PREDICATES.keys()) if channels is None else channels
1627
+ for channel in active:
1628
+ predicate = _CHANNEL_PREDICATES[channel]
1629
+ if predicate(resolved_chart):
1630
+ encoding[channel] = {"value": _NEUTRAL_VALUES[channel]}
1631
+
1632
+
1633
+ def _apply_dash_encoding(
1634
+ resolved_chart: ResolvedChart, encoding: dict[str, Any]
1635
+ ) -> None:
1636
+ """Bind ``strokeDash`` to the categorical color field on line-family charts.
1637
+
1638
+ Fires only when ``chart_has_active_dashes`` returns True AND the color
1639
+ encoding has already been emitted as a field-bound nominal/ordinal channel
1640
+ (which is the same condition under the hood).
1641
+
1642
+ Bound to the same field as ``color`` so the default is redundant encoding
1643
+ (color + dash on the same series). Monochrome theme will set ``dashes`` and
1644
+ a single-color palette, making dash the primary distinguishing channel.
1645
+ """
1646
+ if not chart_has_active_dashes(resolved_chart):
1647
+ return
1648
+ color_enc = encoding.get("color")
1649
+ if not isinstance(color_enc, dict):
1650
+ return
1651
+ field = color_enc.get("field")
1652
+ color_type = color_enc.get("type")
1653
+ if not field or color_type not in {"nominal", "ordinal"}:
1654
+ return
1655
+ # ``scale.range`` carries the actual dash arrays. vl_convert silently
1656
+ # ignores ``config.range.dashPattern`` (documented in Vega-Lite, not
1657
+ # honored by the renderer we use), so the range lives on the encoding
1658
+ # instead.
1659
+ #
1660
+ # When color and strokeDash bind to the same field with matching titles
1661
+ # and clean (un-polluted) scale domains, Vega-Lite merges the two legends
1662
+ # into a single legend whose swatches show both channels together. The
1663
+ # rule-layer strokeDash overrides keep the domain clean. Here we also flip
1664
+ # the color legend's ``symbolType`` to ``stroke`` so the merged-legend
1665
+ # symbol renders as a line sample with the dash pattern applied, rather
1666
+ # than the default filled circle (which displays a dashed circle outline
1667
+ # and is unreadable). ``symbolSize: 2000`` produces a ~45-px line sample —
1668
+ # ≥ one full cycle of every entry in the shipped 5+1 palette (longest
1669
+ # cycle: dash-dot-dot ``[12, 8, 0, 8, 0, 8]`` = 36 px). ``symbolStrokeWidth:
1670
+ # 2`` is a notch below the default 3-px line stroke so the legend reads
1671
+ # compact. Both intentionally override any upstream legend symbol config —
1672
+ # the merged-legend swatch requires these specific values.
1673
+ # The color legend may be explicitly None (theme- or chart-disabled). In
1674
+ # that case the user has opted out of the color legend entirely — leave
1675
+ # strokeDash without a legend rather than resurrecting one.
1676
+ existing_legend = color_enc.get("legend")
1677
+ if existing_legend is None and "legend" in color_enc:
1678
+ # legend: None means "suppressed" — strokeDash gets the same treatment.
1679
+ encoding["strokeDash"] = {
1680
+ "field": field,
1681
+ "type": color_type,
1682
+ "scale": {"range": resolved_chart.resolved_style.dashes},
1683
+ "legend": None,
1684
+ }
1685
+ if "title" in color_enc:
1686
+ encoding["strokeDash"]["title"] = color_enc["title"]
1687
+ return
1688
+
1689
+ color_enc["legend"] = {
1690
+ **(existing_legend or {}),
1691
+ "symbolType": "stroke",
1692
+ "symbolStrokeWidth": 2,
1693
+ "symbolSize": 2000,
1694
+ }
1695
+
1696
+ encoding["strokeDash"] = {
1697
+ "field": field,
1698
+ "type": color_type,
1699
+ "scale": {"range": resolved_chart.resolved_style.dashes},
1700
+ }
1701
+ if "title" in color_enc:
1702
+ encoding["strokeDash"]["title"] = color_enc["title"]
1703
+
1704
+
1705
+ def _apply_chart_sort(resolved_chart: ResolvedChart, encoding: dict[str, Any]) -> None:
1706
+ """Map Dataface sort to Vega-Lite sort on the categorical axis.
1707
+
1708
+ Dataface stores ``ChartSort.order`` as ``"asc" | "desc"`` (canonical
1709
+ form). Vega-Lite's ``sort.order`` accepts only ``"ascending"`` or
1710
+ ``"descending"``; passing the raw Dataface form makes VL silently
1711
+ fall back to ascending, breaking the sort contract. Translate at the
1712
+ emit boundary.
1713
+ """
1714
+ if not resolved_chart.sort:
1715
+ return
1716
+
1717
+ sort_target = {
1718
+ "field": resolved_chart.sort.by,
1719
+ "order": _SORT_ORDER_VL[resolved_chart.sort.order],
1720
+ }
1721
+
1722
+ for axis_name in ("x", "y"):
1723
+ axis_encoding = encoding.get(axis_name)
1724
+ if not isinstance(axis_encoding, dict):
1725
+ continue
1726
+ if axis_encoding.get("type") in ("nominal", "ordinal"):
1727
+ axis_encoding["sort"] = sort_target
1728
+ break
1729
+
1730
+
1731
+ _DF_STACK_ORDER_KEY = "__df_stack_order"
1732
+ """Sort key field computed by the joinaggregate transform in _apply_stacked_bar_z_order.
1733
+
1734
+ The joinaggregate transform computes the global sum of the measure field per color
1735
+ group inside VL's transform pipeline. encoding.order references this column without
1736
+ aggregate — VL evaluates it per-datum so the sort key is stable across all bars.
1737
+ """
1738
+
1739
+ _DF_DATA_ORDER_KEY = "__df_data_order"
1740
+ """Sort key field computed by a VL calculate transform for stack_order='data'.
1741
+
1742
+ The calculate transform uses indexof([first, second, ...], datum.color_field) to
1743
+ assign each datum its series' global first-encounter index. encoding.order references
1744
+ this column with sort: 'ascending' so VL stacks in data-row first-encounter order —
1745
+ consistent across all bars regardless of alphabetical order.
1746
+ """
1747
+
1748
+
1749
+ def _apply_stacked_bar_z_order(
1750
+ resolved_chart: ResolvedChart,
1751
+ encoding: dict[str, Any],
1752
+ data: list[dict[str, Any]] | None = None,
1753
+ ) -> tuple[dict[str, Any], ...]:
1754
+ """Set encoding.order for globally-stable stacking; return the required VL transforms.
1755
+
1756
+ 'value' (default): emits a joinaggregate transform to compute the global sum per
1757
+ color group. encoding.order references the computed field per-datum — stable across
1758
+ all bars, largest series at baseline.
1759
+
1760
+ 'alphabetical': sort by color field name ascending; no transform needed.
1761
+
1762
+ 'data': VL without an explicit encoding.order uses alphabetical domain order for
1763
+ nominal fields, not data row order. To force data-row first-encounter order, emits
1764
+ a calculate transform that assigns each series its global first-encounter index and
1765
+ references it via encoding.order.sort: ascending.
1766
+
1767
+ encoding.order with aggregate: sum is wrong — VL evaluates that aggregate per
1768
+ stack-group (per bar), so each bar re-ranks its segments independently.
1769
+
1770
+ Skipped when stacking is disabled or no color field is present.
1771
+ Returns an empty tuple when no transform is needed.
1772
+ """
1773
+ if resolved_chart.chart_type != "bar":
1774
+ return ()
1775
+ if resolved_chart.stack is False or is_grouped_bar(resolved_chart):
1776
+ return ()
1777
+ color_ch = resolved_chart.resolved_channels.get("color")
1778
+ if color_ch is None or not color_ch.data_field:
1779
+ return ()
1780
+
1781
+ stack_order = resolved_chart.resolved_style.bar.stack_order
1782
+
1783
+ if stack_order == "data":
1784
+ # VL's default for nominal color without encoding.order is alphabetical,
1785
+ # not data row order. Compute the global first-encounter order and emit a
1786
+ # calculate transform so VL stacks in the order rows appear in the data.
1787
+ if not data:
1788
+ return ()
1789
+ seen: dict[str, None] = {}
1790
+ for row in data:
1791
+ s = row.get(color_ch.data_field)
1792
+ if s is not None:
1793
+ seen[str(s)] = None
1794
+ first_encounter = list(seen)
1795
+ if not first_encounter:
1796
+ return ()
1797
+ field_expr = f"datum[{json.dumps(color_ch.data_field)}]"
1798
+ domain_expr = json.dumps(first_encounter)
1799
+ encoding["order"] = {
1800
+ "field": _DF_DATA_ORDER_KEY,
1801
+ "sort": "ascending",
1802
+ }
1803
+ return (
1804
+ {
1805
+ "calculate": f"indexof({domain_expr}, {field_expr})",
1806
+ "as": _DF_DATA_ORDER_KEY,
1807
+ },
1808
+ )
1809
+
1810
+ if stack_order == "alphabetical":
1811
+ color_enc = encoding.get("color", {})
1812
+ encoding["order"] = {
1813
+ "field": color_ch.data_field,
1814
+ "sort": "ascending",
1815
+ "title": color_enc.get(
1816
+ "title",
1817
+ format_display_text(
1818
+ color_ch.data_field,
1819
+ from_slug=True,
1820
+ font=resolved_chart.resolved_style.legend.title.font,
1821
+ ),
1822
+ ),
1823
+ "type": color_enc.get("type", "nominal"),
1824
+ }
1825
+ return ()
1826
+
1827
+ # Default: stack_order is None or "value".
1828
+ # Emit a joinaggregate transform to compute the global sum per color group.
1829
+ # encoding.order references this field without aggregate so VL evaluates it
1830
+ # per-datum — stable across all bars (no per-bar re-aggregation).
1831
+ measure_field = resolved_chart.y
1832
+ if not measure_field:
1833
+ return ()
1834
+ if isinstance(measure_field, list):
1835
+ return ()
1836
+
1837
+ encoding["order"] = {"field": _DF_STACK_ORDER_KEY, "sort": "descending"}
1838
+ return (
1839
+ {
1840
+ "joinaggregate": [
1841
+ {"op": "sum", "field": measure_field, "as": _DF_STACK_ORDER_KEY}
1842
+ ],
1843
+ "groupby": [color_ch.data_field],
1844
+ },
1845
+ )
1846
+
1847
+
1848
+ def _measure_vl_channel(resolved_chart: ResolvedChart) -> Literal["x", "y"]:
1849
+ """Return the VL channel name that carries the measure axis after routing.
1850
+
1851
+ Under dataface semantics axis_y = measure regardless of orientation, but the
1852
+ VL channel that carries the measure flips with orientation: vertical bar
1853
+ (and every other family) puts the measure on VL ``y``; horizontal bar
1854
+ swaps it onto VL ``x``. Pin axis-mode properties (``stack``, ``domainMax``,
1855
+ quantitative format) by the value this returns rather than by literal
1856
+ ``"y"`` so the pin lands on the rendered measure axis after the swap.
1857
+ """
1858
+ if (
1859
+ resolved_chart.chart_type == "bar"
1860
+ and resolved_chart.orientation == "horizontal"
1861
+ ):
1862
+ return "x"
1863
+ return "y"
1864
+
1865
+
1866
+ def _apply_stack_encoding(
1867
+ resolved_chart: ResolvedChart,
1868
+ encoding: dict[str, Any],
1869
+ ) -> None:
1870
+ """Pin the stack mode on the measure-axis encoding.
1871
+
1872
+ When stack=None and a color field is present on a bar chart, emits
1873
+ xOffset/yOffset to produce side-by-side grouped columns (the default).
1874
+
1875
+ ``stack: false`` (or any falsy non-None) emits VL's ``stack: null`` which
1876
+ disables stacking — overlapping layers instead of stacked. Literal modes
1877
+ (``"zero"``, ``"normalize"``, ``"center"``) pass through verbatim.
1878
+
1879
+ Lands on encoding.x for horizontal bars and encoding.y otherwise: VL's
1880
+ ``stack`` semantics are axis-bound, and putting the mode on the categorical
1881
+ encoding does nothing (stack on a nominal scale is meaningless) while
1882
+ leaving the measure unstacked.
1883
+ """
1884
+ stack = resolved_chart.stack
1885
+ if is_grouped_bar(resolved_chart):
1886
+ color_field = effective_color_field(resolved_chart)
1887
+ assert color_field is not None # is_grouped_bar guarantees this
1888
+ offset_ch = (
1889
+ "yOffset" if resolved_chart.orientation == "horizontal" else "xOffset"
1890
+ )
1891
+ color_enc = encoding.get("color", {})
1892
+ encoding[offset_ch] = {
1893
+ "field": color_field,
1894
+ "type": color_enc.get("type", "nominal"),
1895
+ "title": color_enc.get(
1896
+ "title",
1897
+ format_display_text(
1898
+ color_field,
1899
+ from_slug=True,
1900
+ font=resolved_chart.resolved_style.legend.title.font,
1901
+ ),
1902
+ ),
1903
+ }
1904
+ return
1905
+ if stack is None:
1906
+ return
1907
+ measure_channel = _measure_vl_channel(resolved_chart)
1908
+ target = encoding.get(measure_channel)
1909
+ if not isinstance(target, dict):
1910
+ return
1911
+ if stack is False:
1912
+ target["stack"] = None
1913
+ elif isinstance(stack, str):
1914
+ target["stack"] = stack
1915
+ if stack == "normalize":
1916
+ target.setdefault("axis", {}).setdefault(
1917
+ "values", [0, 0.25, 0.5, 0.75, 1.0]
1918
+ )
1919
+
1920
+
1921
+ def _apply_grouped_bar_scale_padding(enc: dict[str, Any]) -> None:
1922
+ """Set paddingInner/paddingOuter on a grouped-bar categorical encoding scale."""
1923
+ scale = enc.setdefault("scale", {})
1924
+ # ScaleStylePatch has no `padding` field, so the only source of `padding` in the
1925
+ # scale dict is the theme cascade (axis_x.scale.padding: 0 → {padding: 0.0}).
1926
+ # VegaLite's spec says `padding` is ignored when explicit inner/outer are present,
1927
+ # but Vega's band scale processes `padding` after them and overrides paddingOuter.
1928
+ # Pop it unconditionally so the explicit values are unambiguous.
1929
+ scale.pop("padding", None)
1930
+ scale.setdefault("paddingInner", _GROUPED_BAR_PADDING_INNER)
1931
+ scale.setdefault("paddingOuter", _GROUPED_BAR_PADDING_OUTER)
1932
+
1933
+
1934
+ def _apply_bar_axis_defaults(
1935
+ resolved_chart: ResolvedChart,
1936
+ encoding: dict[str, Any],
1937
+ ) -> None:
1938
+ """Apply categorical axis defaults for bar charts."""
1939
+ if resolved_chart.chart_type != "bar" or "y" not in encoding:
1940
+ return
1941
+
1942
+ y_enc = encoding["y"]
1943
+ effective = resolved_chart.resolved_style
1944
+ bar = effective.bar
1945
+
1946
+ y_type = y_enc.get("type")
1947
+ # categorical_orient lives on axis_y in theme as a sentinel for the bar
1948
+ # categorical axis orient. Only fires for horizontal bar (VL y is nominal/ordinal).
1949
+ if y_type in ("nominal", "ordinal") and isinstance(y_enc.get("axis"), dict):
1950
+ y_enc["axis"]["orient"] = effective.axis_y.categorical_orient
1951
+ elif y_type in ("nominal", "ordinal"):
1952
+ y_enc["axis"] = {"orient": effective.axis_y.categorical_orient}
1953
+
1954
+ # Resolve bar mark for the padding field (lives in BarMarkStyle, not BarChartStyle).
1955
+ bar_mark = resolve_mark(effective.marks.bar, bar.marks.bar)
1956
+
1957
+ # Wire band padding on the categorical axis encoding scale.
1958
+ # Chart-local ScaleStyle.band_padding_inner wins; bar_mark.padding is the fallback.
1959
+ # Note: VL only honors ``paddingInner`` (not ``bandPaddingInner``) at the
1960
+ # encoding-level scale — see SCALE_FIELD_MAP.
1961
+ #
1962
+ # For grouped bars (xOffset/yOffset): paddingInner creates a visible gap between
1963
+ # groups; paddingOuter prevents the outermost groups from running to the plot edge
1964
+ # and colliding with axis labels.
1965
+ if resolved_chart.orientation == "horizontal":
1966
+ if is_grouped_bar(resolved_chart):
1967
+ _apply_grouped_bar_scale_padding(y_enc)
1968
+ else:
1969
+ y_enc.setdefault("scale", {}).setdefault("paddingInner", bar_mark.padding)
1970
+ else:
1971
+ x_enc = encoding.get("x")
1972
+ if x_enc and x_enc.get("type") in ("nominal", "ordinal"):
1973
+ if is_grouped_bar(resolved_chart):
1974
+ _apply_grouped_bar_scale_padding(x_enc)
1975
+ else:
1976
+ x_enc.setdefault("scale", {}).setdefault(
1977
+ "paddingInner", bar_mark.padding
1978
+ )
1979
+
1980
+
1981
+ def _first_field(value: str | list[str] | None) -> str | None:
1982
+ """Return the first field from a possible multi-field definition."""
1983
+ if isinstance(value, list):
1984
+ return value[0] if value else None
1985
+ return value
1986
+
1987
+
1988
+ def _map_secondary_channels(
1989
+ resolved_chart: ResolvedChart,
1990
+ data: list[dict[str, Any]],
1991
+ channels: tuple[str, ...] = ("color", "size", "shape"),
1992
+ ) -> dict[str, Any]:
1993
+ """Map secondary encoding channels (color, size, shape) through map_other_encoding."""
1994
+ encoding: dict[str, Any] = {}
1995
+ for channel in channels:
1996
+ ch_enc = map_other_encoding(channel, resolved_chart, data)
1997
+ if ch_enc:
1998
+ encoding[channel] = ch_enc
1999
+ return encoding
2000
+
2001
+
2002
+ def _build_standard_encoding(
2003
+ resolved_chart: ResolvedChart,
2004
+ data: list[dict[str, Any]],
2005
+ theme: str | None,
2006
+ y_field: str | None = None,
2007
+ ) -> dict[str, Any]:
2008
+ """Build the canonical standard encoding for cartesian profiled charts."""
2009
+ routing = _build_axis_routing(resolved_chart)
2010
+ encoding: dict[str, Any] = {}
2011
+ x_enc = map_x_encoding(resolved_chart, data, routing=routing)
2012
+ if x_enc:
2013
+ encoding["x"] = x_enc
2014
+ y_enc = map_y_encoding(resolved_chart, data, y_field=y_field, routing=routing)
2015
+ if y_enc:
2016
+ encoding["y"] = y_enc
2017
+ for channel in ("color", "size", "shape"):
2018
+ ch_enc = map_other_encoding(channel, resolved_chart, data)
2019
+ if ch_enc:
2020
+ encoding[channel] = ch_enc
2021
+
2022
+ for src, dst in (
2023
+ ("opacity", "opacity"),
2024
+ ("stroke_color", "stroke"),
2025
+ ("stroke_width", "strokeWidth"),
2026
+ ):
2027
+ ch = resolved_chart.resolved_channels.get(src)
2028
+ if ch is not None and (vl_enc := _resolved_channel_to_vl(ch)):
2029
+ encoding[dst] = vl_enc
2030
+
2031
+ _apply_dash_encoding(resolved_chart, encoding)
2032
+ _apply_chart_sort(resolved_chart, encoding)
2033
+ _apply_bar_axis_defaults(resolved_chart, encoding)
2034
+ _apply_stack_encoding(resolved_chart, encoding)
2035
+
2036
+ # Ensure the measure channel carries the tooltip format so vl-convert renders
2037
+ # formatted numbers in bar element aria-labels (e.g. "Count: 5.00" not "Count: 5").
2038
+ # Do NOT touch axis.format — that drives the scale-domain description, which
2039
+ # uses its own existing format (or default integer rendering).
2040
+ measure_ch = _measure_vl_channel(resolved_chart)
2041
+ menc = encoding.get(measure_ch, {})
2042
+ if menc.get("type") == "quantitative":
2043
+ fmt = resolve_format(
2044
+ resolved_chart.resolved_style.tooltip.format,
2045
+ resolved_chart.resolved_style.formats,
2046
+ )
2047
+ if fmt:
2048
+ menc.setdefault("format", fmt)
2049
+
2050
+ return encoding
2051
+
2052
+
2053
+ # ── Per-family mapping ───────────────────────────────────────────────
2054
+
2055
+
2056
+ def _map_histogram(
2057
+ resolved_chart: ResolvedChart,
2058
+ data: list[dict[str, Any]],
2059
+ ) -> MappedChart:
2060
+ """Map histogram: bar mark with binned x and aggregate count y."""
2061
+ mark = _map_mark(resolved_chart)
2062
+ effective = resolved_chart.resolved_style
2063
+ encoding: dict[str, Any] = {}
2064
+ if resolved_chart.x:
2065
+ encoding["x"] = {
2066
+ "field": resolved_chart.x,
2067
+ "type": "quantitative",
2068
+ "bin": True,
2069
+ "title": format_display_text(
2070
+ resolved_chart.x, from_slug=True, font=effective.axis_x.title.font
2071
+ ),
2072
+ }
2073
+ encoding["y"] = {"aggregate": "count", "type": "quantitative", "title": "Count"}
2074
+ # Histogram only supports color grouping; size/shape not applicable for binned bars
2075
+ encoding.update(_map_secondary_channels(resolved_chart, data, ("color",)))
2076
+ return MappedChart(mark=mark, encoding=encoding)
2077
+
2078
+
2079
+ def _map_boxplot(
2080
+ resolved_chart: ResolvedChart,
2081
+ data: list[dict[str, Any]],
2082
+ ) -> MappedChart:
2083
+ """Map boxplot: composite mark with nominal x and quantitative y."""
2084
+ mark = _map_mark(resolved_chart)
2085
+ mark["extent"] = "min-max"
2086
+ effective = resolved_chart.resolved_style
2087
+ encoding: dict[str, Any] = {}
2088
+ if resolved_chart.x:
2089
+ encoding["x"] = {
2090
+ "field": resolved_chart.x,
2091
+ "type": "nominal",
2092
+ "title": format_display_text(
2093
+ resolved_chart.x, from_slug=True, font=effective.axis_x.title.font
2094
+ ),
2095
+ "axis": _build_encoding_axis(
2096
+ effective,
2097
+ "axis_x",
2098
+ "nominal",
2099
+ _chart_type_axis_patch(effective, "boxplot", "axis_x"),
2100
+ ),
2101
+ }
2102
+ y_field = _first_field(resolved_chart.y)
2103
+ if y_field:
2104
+ encoding["y"] = {
2105
+ "field": y_field,
2106
+ "type": "quantitative",
2107
+ "title": format_display_text(
2108
+ y_field, from_slug=True, font=effective.axis_y.title.font
2109
+ ),
2110
+ }
2111
+ fmt = resolve_format(
2112
+ resolved_chart.resolved_style.tooltip.format,
2113
+ resolved_chart.resolved_style.formats,
2114
+ )
2115
+ if fmt:
2116
+ encoding["y"]["format"] = fmt
2117
+ # Boxplot composite mark: only color grouping is meaningful
2118
+ encoding.update(_map_secondary_channels(resolved_chart, data, ("color",)))
2119
+ return MappedChart(mark=mark, encoding=encoding)
2120
+
2121
+
2122
+ def _map_error(
2123
+ resolved_chart: ResolvedChart,
2124
+ data: list[dict[str, Any]],
2125
+ ) -> MappedChart:
2126
+ """Map errorbar/errorband: composite mark with inferred x type and quantitative y."""
2127
+ mark = _map_mark(resolved_chart)
2128
+ effective = resolved_chart.resolved_style
2129
+ encoding: dict[str, Any] = {}
2130
+ if resolved_chart.x:
2131
+ columns = set(data[0].keys()) if data else set()
2132
+ x_type = (
2133
+ infer_vega_type_from_data(data, resolved_chart.x)
2134
+ if resolved_chart.x in columns
2135
+ else "nominal"
2136
+ )
2137
+ encoding["x"] = {
2138
+ "field": resolved_chart.x,
2139
+ "type": x_type,
2140
+ "title": format_display_text(
2141
+ resolved_chart.x, from_slug=True, font=effective.axis_x.title.font
2142
+ ),
2143
+ }
2144
+ y_field = _first_field(resolved_chart.y)
2145
+ if y_field:
2146
+ encoding["y"] = {
2147
+ "field": y_field,
2148
+ "type": "quantitative",
2149
+ "title": format_display_text(
2150
+ y_field, from_slug=True, font=effective.axis_y.title.font
2151
+ ),
2152
+ }
2153
+ fmt = resolve_format(
2154
+ resolved_chart.resolved_style.tooltip.format,
2155
+ resolved_chart.resolved_style.formats,
2156
+ )
2157
+ if fmt:
2158
+ encoding["y"]["format"] = fmt
2159
+ # Error composite mark: only color grouping is meaningful
2160
+ encoding.update(_map_secondary_channels(resolved_chart, data, ("color",)))
2161
+ return MappedChart(mark=mark, encoding=encoding)
2162
+
2163
+
2164
+ def _map_slice(
2165
+ resolved_chart: ResolvedChart,
2166
+ data: list[dict[str, Any]],
2167
+ ) -> MappedChart:
2168
+ """Map arc/pie: arc mark with theta, optional color and inner_radius.
2169
+
2170
+ Layered emission triggers when ``resolved_chart.total`` (donut center
2171
+ summary) is set. Pixel positioning uses ``width/2`` / ``height/2``
2172
+ expressions so the layout matches whatever size the renderer chooses.
2173
+ """
2174
+ chart_id = resolved_chart.id or "unknown"
2175
+ if not resolved_chart.theta:
2176
+ raise ChartDataError(
2177
+ f"Arc chart '{chart_id}' requires a 'theta' field naming the numeric column",
2178
+ chart_id=chart_id,
2179
+ )
2180
+ arc_mark = _map_mark(resolved_chart)
2181
+ # Disk-size policy: the disk fills 90% of the plot area's shorter
2182
+ # dimension, regardless of label presence. Disk size becomes a function
2183
+ # of cell, not data — a row of donuts produces a row of uniformly
2184
+ # sized disks regardless of sector count. The 10% margin is a visual-
2185
+ # breathing-room gutter; it does NOT functionally contain wedge labels
2186
+ # (those extend beyond the disk via VL plot-area auto-padding anyway).
2187
+ outer_fraction = 0.9
2188
+ arc_mark["outerRadius"] = {"expr": f"min(width, height) / 2 * {outer_fraction}"}
2189
+ # ``inner_radius`` is the ratio of inner hole to outer disk (i.e. the
2190
+ # rendered inner/outer ratio), NOT a fraction of cell-half. Multiply by
2191
+ # ``outer_fraction`` so the rendered ring respects both the disk-size
2192
+ # policy and the configured hole proportion independently.
2193
+ inner_ratio = resolved_chart.resolved_style.pie.inner_radius
2194
+ if inner_ratio is not None and inner_ratio > 0:
2195
+ arc_mark["innerRadius"] = {
2196
+ "expr": f"min(width, height) / 2 * {outer_fraction} * {inner_ratio}"
2197
+ }
2198
+ else:
2199
+ # No author-set inner_radius → solid pie. Set explicitly so VL
2200
+ # doesn't pick up any inherited innerRadius from a parent layer.
2201
+ arc_mark["innerRadius"] = 0
2202
+
2203
+ arc_encoding: dict[str, Any] = {
2204
+ "theta": {
2205
+ "field": resolved_chart.theta,
2206
+ "type": "quantitative",
2207
+ "title": format_display_text(
2208
+ resolved_chart.theta,
2209
+ from_slug=True,
2210
+ font=resolved_chart.resolved_style.legend.title.font,
2211
+ ),
2212
+ },
2213
+ }
2214
+ fmt = resolve_format(
2215
+ resolved_chart.resolved_style.tooltip.format,
2216
+ resolved_chart.resolved_style.formats,
2217
+ )
2218
+ if fmt:
2219
+ arc_encoding["theta"]["format"] = fmt
2220
+ # Arc marks: only color (category) is meaningful; size/shape don't apply
2221
+ arc_encoding.update(_map_secondary_channels(resolved_chart, data, ("color",)))
2222
+
2223
+ # Always augment arc data with __dft_row_idx + __dft_pct + angle meta and
2224
+ # pin the arc order encoding to data insertion sequence — even when this
2225
+ # function early-returns below (no total, no labels). Without it, VL
2226
+ # palette-assigns alphabetically over the color domain while drawing
2227
+ # slices in data-row order, which silently desyncs the attached-table
2228
+ # swatches (palette[idx] in data order) from the rendered wedges. The
2229
+ # color.sort: false below is also necessary for the same reason; hoisted
2230
+ # together so the early-return path gets both.
2231
+ data_override = _augment_arc_label_data(resolved_chart, data)
2232
+ arc_encoding["order"] = {"field": "__dft_row_idx", "type": "quantitative"}
2233
+ if "color" in arc_encoding:
2234
+ arc_encoding["color"]["sort"] = False
2235
+
2236
+ if resolved_chart.total is None and resolved_chart.labels is None:
2237
+ return MappedChart(
2238
+ mark=arc_mark, encoding=arc_encoding, data_override=data_override
2239
+ )
2240
+
2241
+ pie_style = resolved_chart.resolved_style.pie
2242
+ theta_field = resolved_chart.theta
2243
+ sum_field = "__dft_arc_total"
2244
+
2245
+ layers: list[MappedLayer] = [MappedLayer(mark=arc_mark, encoding=arc_encoding)]
2246
+
2247
+ if resolved_chart.total is not None:
2248
+ value_text_mark: dict[str, Any] = {
2249
+ "type": "text",
2250
+ "align": "center",
2251
+ "baseline": "bottom",
2252
+ "tooltip": False,
2253
+ }
2254
+ _apply_font_to_mark(value_text_mark, pie_style.total.value.font)
2255
+ value_text_encoding: dict[str, Any] = {
2256
+ "text": {"field": sum_field, "type": "quantitative"},
2257
+ "x": {"value": {"expr": "width / 2"}},
2258
+ "y": {"value": {"expr": "height / 2"}},
2259
+ }
2260
+ if resolved_chart.total.format is not None:
2261
+ resolved_fmt = resolve_format(
2262
+ resolved_chart.total.format,
2263
+ resolved_chart.resolved_style.formats,
2264
+ )
2265
+ if resolved_fmt is not None:
2266
+ value_text_encoding["text"]["format"] = resolved_fmt
2267
+ layers.append(
2268
+ MappedLayer(
2269
+ mark=value_text_mark,
2270
+ encoding=value_text_encoding,
2271
+ transform=(
2272
+ {
2273
+ "joinaggregate": [
2274
+ {"op": "sum", "field": theta_field, "as": sum_field}
2275
+ ]
2276
+ },
2277
+ {"window": [{"op": "row_number", "as": "__dft_arc_row"}]},
2278
+ {"filter": "datum.__dft_arc_row === 1"},
2279
+ ),
2280
+ )
2281
+ )
2282
+ if resolved_chart.total.label is not None:
2283
+ label_mark: dict[str, Any] = {
2284
+ "type": "text",
2285
+ "align": "center",
2286
+ "baseline": "top",
2287
+ "tooltip": False,
2288
+ }
2289
+ _apply_font_to_mark(label_mark, pie_style.total.label.font)
2290
+ layers.append(
2291
+ MappedLayer(
2292
+ mark=label_mark,
2293
+ encoding={
2294
+ "text": {"value": resolved_chart.total.label},
2295
+ "x": {"value": {"expr": "width / 2"}},
2296
+ "y": {"value": {"expr": "height / 2"}},
2297
+ },
2298
+ transform=(
2299
+ {"window": [{"op": "row_number", "as": "__dft_arc_row"}]},
2300
+ {"filter": "datum.__dft_arc_row === 1"},
2301
+ ),
2302
+ )
2303
+ )
2304
+
2305
+ # Resolved color channel: the `color:` channel mapped to a data field.
2306
+ # ``data_field is not None`` (rather than just ``color_ch is not None``)
2307
+ # is the per-series-color predicate — a color channel can exist with a
2308
+ # literal stop and no field, in which case there are no series to bind.
2309
+ color_ch = resolved_chart.resolved_channels.get("color")
2310
+ series_color_field = (
2311
+ color_ch.data_field
2312
+ if color_ch is not None and color_ch.data_field is not None
2313
+ else None
2314
+ )
2315
+
2316
+ if resolved_chart.labels is not None:
2317
+ slice_mark = resolve_mark(
2318
+ resolved_chart.resolved_style.marks.slice, pie_style.marks.slice
2319
+ )
2320
+ labels_style = slice_mark.labels
2321
+ offset = labels_style.offset
2322
+ block_height = labels_style.block_height
2323
+ label_mark_dict: dict[str, Any] = {
2324
+ "type": "text",
2325
+ "baseline": "top",
2326
+ "lineHeight": labels_style.line_height,
2327
+ "tooltip": False,
2328
+ "align": {"expr": "datum.__dft_right ? 'left' : 'right'"},
2329
+ }
2330
+ # Apply font properties; whether ``font.color`` becomes ``mark.fill``
2331
+ # depends on whether a per-series color encoding will drive label
2332
+ # color (see below). A static ``fill`` on a text mark overrides any
2333
+ # color channel encoding in VL, so we must skip it in the per-series
2334
+ # case — same contract as ``series_label`` in theme YAML.
2335
+ _apply_font_to_mark(
2336
+ label_mark_dict,
2337
+ labels_style.font,
2338
+ skip_color=series_color_field is not None,
2339
+ )
2340
+ label_encoding: dict[str, Any] = {
2341
+ "text": {"field": "__dft_label", "type": "nominal"},
2342
+ "x": {
2343
+ "field": "__dft_x",
2344
+ "type": "quantitative",
2345
+ "scale": None,
2346
+ "axis": None,
2347
+ },
2348
+ "y": {
2349
+ "field": "__dft_y_anchored",
2350
+ "type": "quantitative",
2351
+ "scale": None,
2352
+ "axis": None,
2353
+ },
2354
+ }
2355
+ if series_color_field is not None:
2356
+ # Dark-companion ink: match each slice's bright palette stop to its
2357
+ # readable darker counterpart so labels carry series identity at
2358
+ # the same contrast level endpoint labels use on line/area charts.
2359
+ # Domain order mirrors arc's data-insertion order (the arc encoding
2360
+ # sets ``sort: false``); palette[:n] indexes that same order.
2361
+ seen: list[str] = []
2362
+ for row in data_override or data:
2363
+ v = row.get(series_color_field)
2364
+ if v is None:
2365
+ continue
2366
+ s = str(v)
2367
+ if s not in seen:
2368
+ seen.append(s)
2369
+ palette = list(resolved_chart.resolved_style.palette)
2370
+ dark_stops = resolve_dark_companion_stops(palette[: len(seen)])
2371
+ label_encoding["color"] = {
2372
+ "field": series_color_field,
2373
+ "type": "nominal",
2374
+ "scale": {"domain": seen, "range": dark_stops},
2375
+ "sort": False,
2376
+ "legend": None,
2377
+ }
2378
+ from dataface.core.render.chart.arc_attached_table import (
2379
+ WEDGE_LABEL_MIN_SHARE,
2380
+ )
2381
+
2382
+ label_transforms = (
2383
+ # ``where:``-filtered rows have __dft_label = null. Drop them
2384
+ # before computing positions so VL doesn't render an empty
2385
+ # text mark at every filtered slice's anchor.
2386
+ {"filter": "datum.__dft_label != null"},
2387
+ # Suppress per-wedge labels for small wedges. Even with a clean
2388
+ # tier+visible-count trigger above, individual slim wedges that
2389
+ # squeak through (e.g. an 11% wedge next to a 50% wedge) shed
2390
+ # their label here so callouts don't crowd at near-cardinal
2391
+ # angles. Wedge still renders; hover tooltip carries the value.
2392
+ {"filter": f"datum.__dft_pct > {WEDGE_LABEL_MIN_SHARE}"},
2393
+ # Labels sit at (disk_outer_radius + offset). The disk fills the
2394
+ # cell to ``outer_fraction``; labels live in the gutter just
2395
+ # outside the disk, INSIDE the cell. Without the same
2396
+ # ``outer_fraction`` factor here, VL would reserve plot-area room
2397
+ # for labels beyond the cell edge and shrink the disk to fit.
2398
+ {
2399
+ "calculate": (
2400
+ f"width / 2 + sin(datum.__dft_mid) * "
2401
+ f"(min(width, height) / 2 * {outer_fraction} + {offset})"
2402
+ ),
2403
+ "as": "__dft_x",
2404
+ },
2405
+ {
2406
+ "calculate": (
2407
+ f"height / 2 - cos(datum.__dft_mid) * "
2408
+ f"(min(width, height) / 2 * {outer_fraction} + {offset})"
2409
+ ),
2410
+ "as": "__dft_y",
2411
+ },
2412
+ {
2413
+ "calculate": (
2414
+ f"datum.__dft_y + (datum.__dft_top ? -{block_height} : 0)"
2415
+ ),
2416
+ "as": "__dft_y_anchored",
2417
+ },
2418
+ )
2419
+ layers.append(
2420
+ MappedLayer(
2421
+ mark=label_mark_dict,
2422
+ encoding=label_encoding,
2423
+ transform=label_transforms,
2424
+ )
2425
+ )
2426
+
2427
+ # VL layered specs share the color scale across layers by default. The
2428
+ # label layer's dark-companion scale would otherwise reach back into the
2429
+ # arc layer and dim the slices to label ink — declare the color scale
2430
+ # independent when per-series label color is in play.
2431
+ derived_resolve: dict[str, Any] | None = None
2432
+ if resolved_chart.labels is not None and series_color_field is not None:
2433
+ derived_resolve = {"scale": {"color": "independent"}}
2434
+
2435
+ return MappedChart(
2436
+ layers=tuple(layers),
2437
+ data_override=data_override,
2438
+ derived_resolve=derived_resolve,
2439
+ )
2440
+
2441
+
2442
+ def _coerce_numeric(value: Any) -> Any:
2443
+ """Coerce a CSV-string number to int or float; pass everything else through.
2444
+
2445
+ CSV adapters return raw strings (``"60"``); we want ``int(60)`` so
2446
+ ``{{ value }}`` renders as ``"60"`` rather than ``"60.0"``. Float-shaped
2447
+ strings (``"60.5"``) become ``float``. Non-string / unparseable values
2448
+ pass through unchanged so callers see the surface error rather than a
2449
+ silently coerced value.
2450
+ """
2451
+ if not isinstance(value, str):
2452
+ return value
2453
+ s = value.strip()
2454
+ if not s:
2455
+ return value
2456
+ try:
2457
+ return int(s)
2458
+ except ValueError:
2459
+ pass
2460
+ try:
2461
+ return float(s)
2462
+ except ValueError:
2463
+ return value
2464
+
2465
+
2466
+ def _augment_arc_label_data(
2467
+ resolved_chart: ResolvedChart,
2468
+ data: list[dict[str, Any]],
2469
+ ) -> list[dict[str, Any]]:
2470
+ """Walk arc data; pre-compute angles + (optionally) render label templates.
2471
+
2472
+ Always emits the angle metadata fields the arc renderer relies on:
2473
+ ``__dft_row_idx`` (for the arc ``order`` encoding — pins draw order to
2474
+ data insertion sequence so swatch tables can match), ``__dft_pct``,
2475
+ ``__dft_total``, ``__dft_mid``, ``__dft_top``, ``__dft_right``.
2476
+
2477
+ When ``resolved_chart.labels`` is set, additionally renders the Jinja
2478
+ template per row and emits ``__dft_label``. When labels is None
2479
+ (attached-table mode strips them), the label rendering step is
2480
+ skipped so the donut still draws in the correct order without a
2481
+ label layer.
2482
+ """
2483
+ import math
2484
+
2485
+ from dataface.core.render.chart.labels import prepare_label_data
2486
+ from dataface.core.render.utils import normalize_data_types
2487
+
2488
+ data = normalize_data_types(data)
2489
+ theta_field = resolved_chart.theta or ""
2490
+ color_ch = resolved_chart.resolved_channels.get("color")
2491
+ color_field = color_ch.data_field if color_ch is not None else None
2492
+ total = sum(float(row.get(theta_field, 0) or 0) for row in data)
2493
+ # Compute mid-angle per row using the arc's natural (data-order) draw.
2494
+ running = 0.0
2495
+ angle_meta: list[dict[str, float | bool]] = []
2496
+ for row in data:
2497
+ v = float(row.get(theta_field, 0) or 0)
2498
+ share = v / total if total else 0.0
2499
+ mid = 2 * math.pi * (running + v / 2.0) / total if total else 0.0
2500
+ angle_meta.append(
2501
+ {
2502
+ "__dft_total": total,
2503
+ "__dft_pct": share,
2504
+ "__dft_mid": mid,
2505
+ "__dft_top": math.cos(mid) >= 0,
2506
+ "__dft_right": math.sin(mid) >= 0,
2507
+ "__dft_row_idx": len(angle_meta),
2508
+ }
2509
+ )
2510
+ running += v
2511
+
2512
+ if resolved_chart.labels is None:
2513
+ # Attached-table mode: caller suppressed labels. Skip the Jinja
2514
+ # template render and just return data augmented with the angle
2515
+ # metadata (sufficient for the arc layer's order encoding).
2516
+ return [{**row, **angle_meta[idx]} for idx, row in enumerate(data)]
2517
+
2518
+ def _extras(row: dict[str, Any], index: int) -> dict[str, Any]:
2519
+ meta = angle_meta[index]
2520
+ # Pass the raw row value through. CSV adapter strings are coerced to
2521
+ # float only when arithmetic forces it (Jinja's ``{{ value / 1000 }}``
2522
+ # still works on numeric strings via Python's int/float promotion);
2523
+ # author-side ``{{ value }}`` keeps int → "60" rather than "60.0".
2524
+ return {
2525
+ "percent": meta["__dft_pct"],
2526
+ "value": _coerce_numeric(row.get(theta_field)),
2527
+ "total": total,
2528
+ "color": row.get(color_field) if color_field else None,
2529
+ }
2530
+
2531
+ rendered = prepare_label_data(data, resolved_chart.labels, context_extras=_extras)
2532
+ out: list[dict[str, Any]] = []
2533
+ for idx, row in enumerate(rendered):
2534
+ merged = dict(row)
2535
+ merged.update(angle_meta[idx])
2536
+ out.append(merged)
2537
+ return out
2538
+
2539
+
2540
+ def _apply_font_to_mark(
2541
+ mark: dict[str, Any], font: Any, skip_color: bool = False
2542
+ ) -> None:
2543
+ """Copy non-None font fields onto a VL text mark dict.
2544
+
2545
+ When ``skip_color`` is True, ``font.color`` is NOT copied to ``mark.fill``
2546
+ — caller intends to drive label color via an encoding instead. VL gives
2547
+ ``mark.fill`` precedence over any ``color`` channel on text marks, so a
2548
+ static fill would silently win and collapse per-series labels to one ink.
2549
+ """
2550
+ if font.family is not None:
2551
+ mark["font"] = font.family
2552
+ if font.size is not None:
2553
+ mark["fontSize"] = font.size
2554
+ if font.weight is not None:
2555
+ mark["fontWeight"] = font.weight
2556
+ if font.color is not None and not skip_color:
2557
+ mark["fill"] = font.color
2558
+
2559
+
2560
+ def _map_rect(
2561
+ resolved_chart: ResolvedChart,
2562
+ data: list[dict[str, Any]],
2563
+ ) -> MappedChart:
2564
+ """Map rect/square/heatmap: grid mark with nominal x/y and quantitative color."""
2565
+ mark = _map_mark(resolved_chart)
2566
+ effective = resolved_chart.resolved_style
2567
+ encoding: dict[str, Any] = {}
2568
+ if resolved_chart.x:
2569
+ encoding["x"] = {
2570
+ "field": resolved_chart.x,
2571
+ "type": "nominal",
2572
+ "title": format_display_text(
2573
+ resolved_chart.x, from_slug=True, font=effective.axis_x.title.font
2574
+ ),
2575
+ "axis": _build_encoding_axis(
2576
+ effective,
2577
+ "axis_x",
2578
+ "nominal",
2579
+ _chart_type_axis_patch(effective, resolved_chart.chart_type, "axis_x"),
2580
+ ),
2581
+ }
2582
+ y_field = _first_field(resolved_chart.y)
2583
+ if y_field:
2584
+ encoding["y"] = {
2585
+ "field": y_field,
2586
+ "type": "nominal",
2587
+ "title": format_display_text(
2588
+ y_field, from_slug=True, font=effective.axis_y.title.font
2589
+ ),
2590
+ }
2591
+ # Rect/heatmap: color and size are meaningful; shape doesn't apply
2592
+ encoding.update(_map_secondary_channels(resolved_chart, data, ("color", "size")))
2593
+ if "color" in encoding and resolved_chart.chart_type == "heatmap":
2594
+ encoding["color"]["scale"] = {
2595
+ "scheme": resolved_chart.resolved_style.heatmap.color_scheme
2596
+ }
2597
+ # Rect/heatmap color typically encodes a quantitative measure; carry the
2598
+ # tooltip format so aria-labels render formatted numbers (e.g.
2599
+ # "Value: 1,234.56" not "Value: 1234.56").
2600
+ color_enc = encoding.get("color")
2601
+ if color_enc and color_enc.get("type") == "quantitative":
2602
+ fmt = resolve_format(
2603
+ resolved_chart.resolved_style.tooltip.format,
2604
+ resolved_chart.resolved_style.formats,
2605
+ )
2606
+ if fmt:
2607
+ color_enc.setdefault("format", fmt)
2608
+ return MappedChart(mark=mark, encoding=encoding)
2609
+
2610
+
2611
+ def _map_line(
2612
+ resolved_chart: ResolvedChart,
2613
+ data: list[dict[str, Any]],
2614
+ width: float | None = None,
2615
+ theme: str | None = None,
2616
+ background: str | None = None,
2617
+ ) -> MappedChart:
2618
+ """Map a single-series line chart, emitting halo layers when configured.
2619
+
2620
+ A ``halo_multiplier`` of 0 or a missing background falls back to the plain
2621
+ single-mark line; otherwise the chart is emitted as 2 line layers (halo +
2622
+ foreground), and as 4 layers when points are enabled (knockout halo
2623
+ points + foreground points). The halo's stroke is set to the chart's
2624
+ effective background so crossings read cleanly against the canvas.
2625
+ """
2626
+ encoding = _build_standard_encoding(resolved_chart, data, theme)
2627
+ charts = resolved_chart.resolved_style
2628
+ line_style = charts.line
2629
+ line_marks = line_style.marks
2630
+ line_mark = resolve_mark(charts.marks.line, line_marks.line)
2631
+ point_mark = resolve_mark(charts.marks.point, line_marks.point)
2632
+
2633
+ if line_mark.halo_multiplier <= 0 or background is None:
2634
+ mark = {**_map_mark(resolved_chart), "aria": False}
2635
+ return MappedChart(
2636
+ encoding=encoding,
2637
+ layers=(
2638
+ MappedLayer(mark=mark),
2639
+ MappedLayer(mark=_hover_overlay_point()),
2640
+ ),
2641
+ )
2642
+
2643
+ from dataface.core.render.chart.vl_field_maps import (
2644
+ _emit_point_mark,
2645
+ line_mark_to_vl,
2646
+ )
2647
+
2648
+ # LineMarkStyle guarantees stroke.width is not None.
2649
+ assert line_mark.stroke.width is not None
2650
+ halo_width = line_mark.stroke.width * line_mark.halo_multiplier
2651
+ # Canonical mapper covers all mark fields (stroke, interpolate, etc.).
2652
+ # Halo overrides stroke/strokeWidth with background color and wider width;
2653
+ # fg inherits them as-is. New StrokeStyle fields flow automatically.
2654
+ mark_vl = line_mark_to_vl(line_mark)
2655
+ # Force opacity to 1 on every halo channel. Some themes set per-mark
2656
+ # opacity defaults (e.g. line.opacity, point.opacity for soft scatter
2657
+ # styling) that would otherwise modulate the halo and let the colored
2658
+ # foreground bleed through, which defeats the knockout effect.
2659
+ halo_line = {
2660
+ "type": "line",
2661
+ **mark_vl,
2662
+ # Override: halo uses background color and wider stroke.
2663
+ "stroke": background,
2664
+ "strokeWidth": halo_width,
2665
+ "opacity": 1,
2666
+ "strokeOpacity": 1,
2667
+ "tooltip": False,
2668
+ # Halo protrudes past the fg stroke — suppress its aria-label so the
2669
+ # edge band between data points doesn't trigger a first-datum tooltip.
2670
+ "aria": False,
2671
+ }
2672
+ # aria: False suppresses the single-path first-datum aria-label; the
2673
+ # transparent point overlay below provides per-datum labels instead.
2674
+ fg_line = {"type": "line", **mark_vl, "tooltip": True, "aria": False}
2675
+
2676
+ # Layer order: ALL halo layers first, then ALL foreground layers, so the
2677
+ # foreground line is never occluded by a point halo at the data points.
2678
+ # The invisible point overlay sits last so it captures mouse events.
2679
+ layers: list[MappedLayer] = [MappedLayer(mark=halo_line)]
2680
+
2681
+ if point_mark.size > 0:
2682
+ halo_size = point_mark.size * line_mark.halo_multiplier
2683
+ layers.append(
2684
+ MappedLayer(
2685
+ mark={
2686
+ "type": "point",
2687
+ "filled": True,
2688
+ "fill": background,
2689
+ "stroke": background,
2690
+ "size": halo_size,
2691
+ "opacity": 1,
2692
+ "fillOpacity": 1,
2693
+ "strokeOpacity": 1,
2694
+ "tooltip": False,
2695
+ }
2696
+ )
2697
+ )
2698
+
2699
+ layers.append(MappedLayer(mark=fg_line))
2700
+
2701
+ if point_mark.size > 0:
2702
+ layers.append(
2703
+ MappedLayer(
2704
+ mark={
2705
+ "type": "point",
2706
+ **_emit_point_mark(point_mark),
2707
+ # Force opacity 1: theme circle/scatter configs default to 0.7,
2708
+ # and VL applies those to 'point' marks too. A semi-transparent
2709
+ # foreground point lets the halo bleed through.
2710
+ "opacity": 1,
2711
+ "fillOpacity": 1,
2712
+ "strokeOpacity": 1,
2713
+ "tooltip": True,
2714
+ }
2715
+ )
2716
+ )
2717
+
2718
+ layers.append(MappedLayer(mark=_hover_overlay_point()))
2719
+
2720
+ return MappedChart(encoding=encoding, layers=tuple(layers))
2721
+
2722
+
2723
+ def _map_area(
2724
+ resolved_chart: ResolvedChart,
2725
+ data: list[dict[str, Any]],
2726
+ width: float | None = None,
2727
+ theme: str | None = None,
2728
+ background: str | None = None,
2729
+ ) -> MappedChart:
2730
+ """Map an area chart, emitting a halo undercoat layer for multi-series.
2731
+
2732
+ Single-series area is a flat single mark. Multi-series area (series
2733
+ color encoding) gets a cream-knockout undercoat per series so the
2734
+ soft colored fills don't mix where they overlap; the colored areas
2735
+ sit on top at the resolved area opacity. Mirrors the line family's
2736
+ halo, but for area marks.
2737
+
2738
+ Fill and stroke are emitted as separate layers so that
2739
+ ``_inject_zero_baseline_rule`` can insert the zero baseline between
2740
+ the fg fill and the fg stroke:
2741
+ [halo_fill, halo_stroke, fg_fill, zero_rule, fg_stroke, hover_overlay].
2742
+ Halo layers precede their fg counterparts so the knockout undercoat
2743
+ renders behind the colored fill; the fg stroke sits above the baseline rule.
2744
+ """
2745
+ encoding = _build_standard_encoding(resolved_chart, data, theme)
2746
+ charts = resolved_chart.resolved_style
2747
+ area_style = charts.area
2748
+ area_marks = area_style.marks
2749
+ area_mark = resolve_mark(charts.marks.area, area_marks.area)
2750
+
2751
+ # Apply the area family's stack default when the chart didn't author one.
2752
+ # ``_apply_stack_encoding`` only fires for non-None ``chart.stack`` and
2753
+ # leaves the encoding bare otherwise — VL would then default to
2754
+ # ``stack: "zero"`` for area marks. Theme YAML's ``area.stack`` lets the
2755
+ # area family default to overlapping (false) without each chart
2756
+ # re-authoring it. Pin on the measure channel so the rule is symmetric
2757
+ # with ``_apply_stack_encoding`` (area today never swaps orientation, so
2758
+ # this resolves to ``"y"``; the helper keeps the contract explicit).
2759
+ measure_channel = _measure_vl_channel(resolved_chart)
2760
+ if resolved_chart.stack is None and "stack" not in encoding.get(
2761
+ measure_channel, {}
2762
+ ):
2763
+ if area_style.stack is False:
2764
+ encoding.setdefault(measure_channel, {})["stack"] = None
2765
+ elif isinstance(area_style.stack, str):
2766
+ encoding.setdefault(measure_channel, {})["stack"] = area_style.stack
2767
+
2768
+ color_ch = resolved_chart.resolved_channels.get("color")
2769
+ is_multi_series = (
2770
+ color_ch is not None and color_ch.mode == "series" and bool(color_ch.data_field)
2771
+ )
2772
+
2773
+ from dataface.core.render.chart.vl_field_maps import _stroke_to_vl
2774
+
2775
+ if area_mark.halo_multiplier <= 0 or background is None:
2776
+ # No halo: fill-only area + separate line mark for top-edge stroke.
2777
+ # Suppress the area boundary stroke; the line mark carries it instead.
2778
+ base = {**_map_mark(resolved_chart), "aria": False}
2779
+ area_fill = {
2780
+ k: v
2781
+ for k, v in base.items()
2782
+ if k
2783
+ not in ("stroke", "strokeWidth", "strokeCap", "strokeJoin", "strokeDash")
2784
+ }
2785
+ area_fill["strokeOpacity"] = 0
2786
+ fg_line = {
2787
+ "type": "line",
2788
+ "strokeCap": "round",
2789
+ "strokeJoin": "round",
2790
+ **_stroke_to_vl(area_mark.stroke),
2791
+ "aria": False,
2792
+ "tooltip": True,
2793
+ }
2794
+ return MappedChart(
2795
+ encoding=encoding,
2796
+ layers=(
2797
+ MappedLayer(mark=area_fill),
2798
+ # zero rule injected by _inject_zero_baseline_rule after the area layer
2799
+ MappedLayer(mark=fg_line),
2800
+ MappedLayer(mark=_hover_overlay_point()),
2801
+ ),
2802
+ )
2803
+
2804
+ # Halo stroke width mirrors the line halo pattern: wider background-color
2805
+ # stroke drawn before the colored foreground so crossings read cleanly.
2806
+ # AreaMarkStyle guarantees stroke.width is not None.
2807
+ assert area_mark.stroke.width is not None
2808
+ halo_stroke_width = area_mark.stroke.width * area_mark.halo_multiplier
2809
+
2810
+ # Fill-only area marks. strokeOpacity: 0 suppresses the area boundary
2811
+ # stroke; separate line marks below carry the top-edge stroke so that
2812
+ # _inject_zero_baseline_rule can place the zero rule between fill and stroke.
2813
+ halo_area_fill = {
2814
+ "type": "area",
2815
+ "fill": background,
2816
+ "opacity": 1,
2817
+ "fillOpacity": 1,
2818
+ "strokeOpacity": 0,
2819
+ "tooltip": False,
2820
+ # Halo protrudes past the fg stroke — suppress its aria-label for the
2821
+ # same reason as halo_line: it carries a first-datum label on the
2822
+ # single area path and would trigger in the edge bands.
2823
+ "aria": False,
2824
+ }
2825
+ # Mark-level ``opacity`` multiplies fill/stroke opacities, so we keep
2826
+ # it at 1 and push the soft tint into ``fillOpacity`` only — that way
2827
+ # the colored stroke renders at full intensity over a soft fill.
2828
+ # aria: False suppresses the single-path first-datum aria-label; the
2829
+ # transparent point overlay below provides per-datum labels instead.
2830
+ fg_area_fill = {
2831
+ "type": "area",
2832
+ "opacity": 1,
2833
+ "tooltip": True,
2834
+ "aria": False,
2835
+ "fillOpacity": area_mark.opacity,
2836
+ "strokeOpacity": 0,
2837
+ }
2838
+
2839
+ # Separate stroke (line) layers. Previously these were embedded via
2840
+ # ``area.line: {...}`` which bundled fill + stroke into one VL layer,
2841
+ # making it impossible to insert the zero rule between them.
2842
+ # Note: a VL ``line`` mark on the same encoding as the area mark traces
2843
+ # the same data path as the area's top edge for non-stacked areas.
2844
+ halo_line_mark = {
2845
+ "type": "line",
2846
+ "stroke": background,
2847
+ "strokeWidth": halo_stroke_width,
2848
+ "strokeCap": "round",
2849
+ "strokeJoin": "round",
2850
+ "tooltip": False,
2851
+ "aria": False,
2852
+ }
2853
+ fg_line_mark = {
2854
+ "type": "line",
2855
+ # Aesthetic defaults for area top-edge line; model overrides win.
2856
+ "strokeCap": "round",
2857
+ "strokeJoin": "round",
2858
+ **_stroke_to_vl(area_mark.stroke),
2859
+ "aria": False,
2860
+ "tooltip": True,
2861
+ }
2862
+
2863
+ if is_multi_series:
2864
+ # The halo layers override the inherited color encoding to a constant
2865
+ # background fill/stroke, but keep series grouping via ``detail`` so
2866
+ # each series gets its own silhouette beneath its colored counterpart.
2867
+ assert (
2868
+ color_ch is not None and color_ch.data_field
2869
+ ) # narrowed by is_multi_series
2870
+ halo_encoding: dict[str, Any] = {
2871
+ "color": {"value": background},
2872
+ "detail": {"field": color_ch.data_field, "type": "nominal"},
2873
+ }
2874
+ return MappedChart(
2875
+ encoding=encoding,
2876
+ layers=(
2877
+ MappedLayer(mark=halo_area_fill, encoding=halo_encoding),
2878
+ MappedLayer(mark=halo_line_mark, encoding=halo_encoding),
2879
+ MappedLayer(mark=fg_area_fill),
2880
+ # zero rule injected by _inject_zero_baseline_rule after last area layer
2881
+ MappedLayer(mark=fg_line_mark),
2882
+ MappedLayer(mark=_hover_overlay_point()),
2883
+ ),
2884
+ )
2885
+
2886
+ # Single-series: halo fill + halo stroke + fg fill + (zero rule) + fg stroke + hover overlay.
2887
+ return MappedChart(
2888
+ encoding=encoding,
2889
+ layers=(
2890
+ MappedLayer(mark=halo_area_fill),
2891
+ MappedLayer(mark=halo_line_mark),
2892
+ MappedLayer(mark=fg_area_fill),
2893
+ # zero rule injected by _inject_zero_baseline_rule after last area layer
2894
+ MappedLayer(mark=fg_line_mark),
2895
+ MappedLayer(mark=_hover_overlay_point()),
2896
+ ),
2897
+ )
2898
+
2899
+
2900
+ def _map_layered_chart(
2901
+ resolved_chart: ResolvedChart,
2902
+ data: list[dict[str, Any]],
2903
+ width: float | None = None,
2904
+ theme: str | None = None,
2905
+ ) -> MappedChart:
2906
+ """Map a multi-metric profiled chart into a single layered profile contract."""
2907
+ y_fields = resolved_chart.y
2908
+ if not isinstance(y_fields, list):
2909
+ raise TypeError("_map_layered_chart requires a multi-field y definition")
2910
+
2911
+ base_encoding = _build_standard_encoding(
2912
+ resolved_chart,
2913
+ data,
2914
+ theme,
2915
+ y_field=None,
2916
+ )
2917
+ base_encoding.pop("y", None)
2918
+
2919
+ layers: list[MappedLayer] = []
2920
+ for metric in y_fields:
2921
+ layer_encoding = dict(base_encoding)
2922
+ # skip_tick_values: each layer maps its own metric range; per-layer
2923
+ # axis.values conflict on the shared y-scale. Callers must compute
2924
+ # tick values from the full union extent across all metrics separately.
2925
+ y_encoding = map_y_encoding(
2926
+ resolved_chart, data, y_field=metric, skip_tick_values=True
2927
+ )
2928
+ if y_encoding is None:
2929
+ chart_id = resolved_chart.id or "unknown"
2930
+ raise ChartDataError(
2931
+ f"Layered chart '{chart_id}' could not map y field '{metric}'",
2932
+ chart_id=chart_id,
2933
+ )
2934
+ layer_encoding["y"] = y_encoding
2935
+ _apply_stack_encoding(resolved_chart, layer_encoding)
2936
+
2937
+ if (
2938
+ resolved_chart.chart_type in {"line", "area"}
2939
+ and "color" not in layer_encoding
2940
+ ):
2941
+ effective = resolved_chart.resolved_style
2942
+ color_enc: dict[str, Any] = {
2943
+ "datum": format_display_text(
2944
+ metric, from_slug=True, font=effective.legend.title.font
2945
+ )
2946
+ }
2947
+ legend = _build_encoding_legend(effective)
2948
+ if legend is not None or effective.legend.disable is True:
2949
+ color_enc["legend"] = legend
2950
+ layer_encoding["color"] = color_enc
2951
+
2952
+ mark = _map_mark(resolved_chart)
2953
+ if resolved_chart.chart_type in {"line", "area"}:
2954
+ # Suppress the single-path first-datum aria-label; the point overlay
2955
+ # appended below provides per-datum labels for each metric.
2956
+ mark = {**mark, "aria": False}
2957
+ layers.append(MappedLayer(mark=mark, encoding=layer_encoding))
2958
+
2959
+ if resolved_chart.chart_type in {"line", "area"}:
2960
+ # Each line/area metric gets its own invisible point overlay so the
2961
+ # JS hover layer resolves to individual data points.
2962
+ layers.append(
2963
+ MappedLayer(mark=_hover_overlay_point(), encoding=layer_encoding)
2964
+ )
2965
+
2966
+ return MappedChart(encoding=base_encoding, layers=tuple(layers))
2967
+
2968
+
2969
+ def _map_explicit_layered_chart(
2970
+ resolved_chart: ResolvedChart,
2971
+ data: list[dict[str, Any]],
2972
+ width: float | None = None,
2973
+ theme: str | None = None,
2974
+ datasets: dict[str, list[dict[str, Any]]] | None = None,
2975
+ ) -> MappedChart:
2976
+ """Map an explicit ``type: layered`` chart into a layered profile contract.
2977
+
2978
+ Each authored layer specifies its own mark type and may override
2979
+ parent-level channels. Parent-level ``x``, ``color``, ``size``,
2980
+ ``shape`` supply shared defaults; per-layer ``y``, ``color``, etc.
2981
+ take precedence when present.
2982
+
2983
+ When layers carry per-layer ``query_name`` / ``x``, each layer is
2984
+ tagged with a ``data_name`` and gets its own x encoding derived from
2985
+ its own dataset. The resulting ``MappedChart.datasets`` dict is used
2986
+ by the spec generator to emit VL ``"datasets"`` + per-layer
2987
+ ``"data": {"name": ...}`` references.
2988
+ """
2989
+ chart_layers = resolved_chart.layers
2990
+ if not chart_layers:
2991
+ raise ValueError(
2992
+ f"Layered chart '{resolved_chart.id}' has no `layers`. "
2993
+ "Each layered chart must define at least one layer."
2994
+ )
2995
+
2996
+ root_cf = resolved_chart.source_chart.conditional_formatting
2997
+
2998
+ # Validate CF keys up front so bad input fails before any layer work.
2999
+ if root_cf:
3000
+ layer_y_fields = {cl.y for cl in chart_layers if cl.y}
3001
+ orphaned = [col for col in root_cf if col not in layer_y_fields]
3002
+ if orphaned:
3003
+ raise ValueError(
3004
+ f"conditional_formatting on layered chart '{resolved_chart.id}' targets "
3005
+ f"column(s) {orphaned!r} which don't match any layer's y field. "
3006
+ f"Layer y fields: {sorted(layer_y_fields)}"
3007
+ )
3008
+
3009
+ has_per_layer_queries = datasets and any(
3010
+ cl.query_name is not None for cl in chart_layers
3011
+ )
3012
+
3013
+ # Build shared base encoding from parent-level channels (no y — layers own y)
3014
+ base_encoding = _build_standard_encoding(resolved_chart, data, theme, y_field=None)
3015
+ base_encoding.pop("y", None)
3016
+
3017
+ # Resolve per-layer axis_y.orient with auto-fill: if any layer pinned a side,
3018
+ # unset layers default to the opposite side. All-same / all-unset → no fill.
3019
+ explicit_orients = {
3020
+ cl.axis_y.get("orient")
3021
+ for cl in chart_layers
3022
+ if cl.axis_y and cl.axis_y.get("orient") in ("left", "right")
3023
+ }
3024
+ if explicit_orients == {"right"}:
3025
+ unset_default_orient: str | None = "left"
3026
+ elif explicit_orients == {"left"}:
3027
+ unset_default_orient = "right"
3028
+ else:
3029
+ unset_default_orient = None
3030
+ derived_independent_y = len(explicit_orients) >= 2 or (
3031
+ unset_default_orient is not None
3032
+ and any(not (cl.axis_y and cl.axis_y.get("orient")) for cl in chart_layers)
3033
+ )
3034
+
3035
+ layers: list[MappedLayer] = []
3036
+ for chart_layer in chart_layers:
3037
+ vl_mark_type = CHART_TYPE_MAP.get(chart_layer.chart_type)
3038
+ if vl_mark_type is None:
3039
+ from dataface.core.errors import DF_RENDER_UNKNOWN_CHART_TYPE
3040
+
3041
+ raise UnknownChartType.from_code(
3042
+ DF_RENDER_UNKNOWN_CHART_TYPE,
3043
+ chart_type=chart_layer.chart_type,
3044
+ available=", ".join(sorted(CHART_TYPE_MAP.keys())),
3045
+ )
3046
+
3047
+ mark: dict[str, Any] = {"type": vl_mark_type, "tooltip": True}
3048
+ # Per-layer mark-style emission. The non-layered path runs through
3049
+ # _build_mark_style → bar_mark_to_vl so authored bar.size /
3050
+ # band_width / border etc. land on the mark. The explicit-layered
3051
+ # path was emitting bare {"type": "bar"} only — bars on a temporal
3052
+ # x-scale fell back to VL's continuousBandSize default of 5 px,
3053
+ # producing the thin-bar look in interval-label-centering-lab. Run
3054
+ # the same per-mark-type emit here.
3055
+ from dataface.core.compile.models.style.merged import resolve_mark
3056
+ from dataface.core.render.chart.vl_field_maps import (
3057
+ area_mark_to_vl,
3058
+ bar_mark_to_vl,
3059
+ line_mark_to_vl,
3060
+ scatter_mark_to_vl,
3061
+ )
3062
+
3063
+ eff = resolved_chart.resolved_style
3064
+ pm_separate = False # set True only for line layers with explicit point.color
3065
+ pm: Any = None
3066
+ if chart_layer.chart_type == "bar":
3067
+ bar_mark = resolve_mark(eff.marks.bar, eff.bar.marks.bar)
3068
+ mark.update(bar_mark_to_vl(bar_mark, "vertical"))
3069
+ elif chart_layer.chart_type == "line":
3070
+ line_marks = eff.line.marks
3071
+ lm = resolve_mark(eff.marks.line, line_marks.line)
3072
+ pm = resolve_mark(eff.marks.point, line_marks.point)
3073
+ # When point.color is explicitly set, don't embed mark.point — VL's
3074
+ # encoding.color (datum-based) overrides mark.point.color but not a
3075
+ # separate layer's encoding.color={value:…}. Emit points as a
3076
+ # separate layer instead (handled below after layer_encoding is built).
3077
+ pm_separate = pm.color is not None and pm.size > 0
3078
+ mark.update(line_mark_to_vl(lm, None if pm_separate else pm))
3079
+ # Suppress the single-path first-datum aria-label; the point overlay
3080
+ # appended after this layer provides per-datum aria-labels instead.
3081
+ mark["aria"] = False
3082
+ elif chart_layer.chart_type == "area":
3083
+ area_mark = resolve_mark(eff.marks.area, eff.area.marks.area)
3084
+ mark.update(area_mark_to_vl(area_mark))
3085
+ mark["aria"] = False
3086
+ elif chart_layer.chart_type in ("scatter", "circle", "point"):
3087
+ pm = resolve_mark(eff.marks.point, eff.scatter.marks.point)
3088
+ mark.update(scatter_mark_to_vl(pm))
3089
+ layer_encoding = dict(base_encoding)
3090
+
3091
+ # Determine which dataset this layer uses
3092
+ layer_data_name: str | None = None
3093
+ layer_data = data
3094
+ if has_per_layer_queries:
3095
+ layer_query = chart_layer.query_name or resolved_chart.query_name
3096
+ if layer_query and datasets and layer_query in datasets:
3097
+ layer_data_name = layer_query
3098
+ layer_data = datasets[layer_query]
3099
+
3100
+ # Per-layer gap-fill: each layer's dataset is independently gap-filled.
3101
+ # Use the layer's x field (if present) and parent style for trigger detection.
3102
+ if has_per_layer_queries and chart_layer.x:
3103
+ layer_chart_for_fill = replace(
3104
+ resolved_chart, x=chart_layer.x, x_label=None
3105
+ )
3106
+ layer_data = _gap_fill(layer_chart_for_fill, layer_data)
3107
+ if layer_data_name is not None and datasets is not None:
3108
+ datasets[layer_data_name] = layer_data
3109
+
3110
+ # Per-layer x: build encoding for the layer's own x field through the
3111
+ # full scale-type decision path. Use replace() so map_x_encoding sees
3112
+ # the layer's field while inheriting all axis style (time_unit,
3113
+ # axis_x.type).
3114
+ if chart_layer.x and has_per_layer_queries:
3115
+ layer_chart = replace(resolved_chart, x=chart_layer.x, x_label=None)
3116
+ x_enc = map_x_encoding(layer_chart, layer_data)
3117
+ if x_enc is not None:
3118
+ layer_encoding["x"] = x_enc
3119
+
3120
+ y_field = chart_layer.y
3121
+ if not y_field:
3122
+ raise ChartDataError(
3123
+ f"Layer '{chart_layer.chart_type}' in chart "
3124
+ f"'{resolved_chart.id}' has no `y` field. "
3125
+ "Each layer in a layered chart must specify a `y` field.",
3126
+ chart_id=resolved_chart.id or "unknown",
3127
+ )
3128
+ # skip_tick_values: per-layer axis.values conflict on the shared y-scale.
3129
+ y_enc = map_y_encoding(
3130
+ resolved_chart, layer_data, y_field=y_field, skip_tick_values=True
3131
+ )
3132
+ if y_enc is None:
3133
+ raise ChartDataError(
3134
+ f"Layered chart '{resolved_chart.id}' could not map y field "
3135
+ f"'{y_field}' for layer '{chart_layer.chart_type}'",
3136
+ chart_id=resolved_chart.id or "unknown",
3137
+ )
3138
+ layer_encoding["y"] = y_enc
3139
+ _apply_stack_encoding(resolved_chart, layer_encoding)
3140
+
3141
+ # Per-layer CF: if root conditional_formatting targets this layer's y field,
3142
+ # inject a VL conditional color encoding. A typed layer color channel
3143
+ # (below) replaces it if both are authored.
3144
+ if (
3145
+ root_cf
3146
+ and y_field in root_cf
3147
+ and chart_layer.chart_type in frozenset({"bar", "line", "area", "scatter"})
3148
+ ):
3149
+ cf_entry = root_cf[y_field]
3150
+ field_ref = f"datum[{json.dumps(y_field)}]"
3151
+ vl_condition = _cf_rules_to_vl_condition(cf_entry.when, field_ref)
3152
+ if vl_condition:
3153
+ layer_encoding["color"] = vl_condition
3154
+
3155
+ # Per-layer channel overrides
3156
+ for channel, default_type in (
3157
+ ("color", "nominal"),
3158
+ ("size", "quantitative"),
3159
+ ("shape", "nominal"),
3160
+ ):
3161
+ layer_field = getattr(chart_layer, channel, None)
3162
+ if layer_field is None:
3163
+ continue
3164
+ if isinstance(layer_field, dict):
3165
+ ch = parse_style_channel(layer_field, channel)
3166
+ vl_enc = _resolved_channel_to_vl(ch)
3167
+ if vl_enc is not None:
3168
+ layer_encoding[channel] = vl_enc
3169
+ else:
3170
+ columns = set(layer_data[0].keys()) if layer_data else set()
3171
+ inferred = (
3172
+ infer_vega_type_from_data(layer_data, layer_field)
3173
+ if layer_field in columns
3174
+ else default_type
3175
+ )
3176
+ layer_encoding[channel] = {"field": layer_field, "type": inferred}
3177
+
3178
+ # Per-layer static fill color — bypasses encoding channel system.
3179
+ # Datum injection is also suppressed below (fill is None guard).
3180
+ if chart_layer.fill is not None:
3181
+ mark["fill"] = chart_layer.fill
3182
+
3183
+ # Typed per-layer axis_y: orient + title.
3184
+ layer_orient = (
3185
+ chart_layer.axis_y.get("orient") if chart_layer.axis_y else None
3186
+ ) or unset_default_orient
3187
+ layer_title = chart_layer.axis_y.get("title") if chart_layer.axis_y else None
3188
+ if layer_orient or layer_title:
3189
+ y_enc_for_axis = dict(layer_encoding.get("y", {}))
3190
+ axis_dict = dict(y_enc_for_axis.get("axis") or {})
3191
+ if layer_orient:
3192
+ axis_dict["orient"] = layer_orient
3193
+ if layer_title:
3194
+ axis_dict["title"] = layer_title
3195
+ y_enc_for_axis["axis"] = axis_dict
3196
+ layer_encoding["y"] = y_enc_for_axis
3197
+
3198
+ # Assign a constant-datum color so Vega-Lite walks the palette per layer.
3199
+ # Only fires on data-series marks when no color was authored at parent or
3200
+ # layer level. Annotation marks (text, rule, tick, image) are excluded so
3201
+ # they don't appear as spurious entries in the auto-built legend.
3202
+ # Skip when layer.fill is set — the mark is intentionally static-painted.
3203
+ if (
3204
+ "color" not in layer_encoding
3205
+ and chart_layer.chart_type in _DATA_SERIES_LAYER_TYPES
3206
+ and chart_layer.fill is None
3207
+ ):
3208
+ effective = resolved_chart.resolved_style
3209
+ datum_label = chart_layer.label or format_display_text(
3210
+ y_field, from_slug=True, font=effective.legend.title.font
3211
+ )
3212
+ color_enc: dict[str, Any] = {"datum": datum_label}
3213
+ legend = _build_encoding_legend(effective)
3214
+ if legend is not None or effective.legend.disable is True:
3215
+ color_enc["legend"] = legend
3216
+ layer_encoding["color"] = color_enc
3217
+
3218
+ layers.append(
3219
+ MappedLayer(
3220
+ mark=mark,
3221
+ encoding=layer_encoding,
3222
+ data_name=layer_data_name,
3223
+ )
3224
+ )
3225
+
3226
+ if chart_layer.chart_type == "line" and pm_separate:
3227
+ # Separate visible point layer: encoding.color={value:…} pins the
3228
+ # explicit color so VL's datum-based color encoding can't override it.
3229
+ from dataface.core.render.chart.vl_field_maps import _emit_point_mark
3230
+
3231
+ pt_encoding = {**layer_encoding, "color": {"value": pm.color}}
3232
+ layers.append(
3233
+ MappedLayer(
3234
+ mark={"type": "point", **_emit_point_mark(pm), "aria": False},
3235
+ encoding=pt_encoding,
3236
+ data_name=layer_data_name,
3237
+ )
3238
+ )
3239
+
3240
+ if chart_layer.chart_type in ("line", "area"):
3241
+ # Each line/area sub-layer gets its own invisible point overlay so the
3242
+ # JS hover layer resolves to individual data points (not the single path).
3243
+ layers.append(
3244
+ MappedLayer(
3245
+ mark=_hover_overlay_point(),
3246
+ encoding=layer_encoding,
3247
+ data_name=layer_data_name,
3248
+ )
3249
+ )
3250
+
3251
+ return MappedChart(
3252
+ encoding=base_encoding,
3253
+ layers=tuple(layers),
3254
+ datasets=datasets if has_per_layer_queries else None,
3255
+ derived_resolve=(
3256
+ {"scale": {"y": "independent"}} if derived_independent_y else None
3257
+ ),
3258
+ )
3259
+
3260
+
3261
+ # ── Gap filling for ordinal bucketed-time charts ──────────────────────
3262
+
3263
+
3264
+ def _resolve_ordinal_time_unit(
3265
+ resolved_chart: ResolvedChart,
3266
+ data: list[dict[str, Any]],
3267
+ ) -> str | None:
3268
+ """Return the time_unit if the bucketed-time ordinal trigger fires; else None.
3269
+
3270
+ Mirrors the time_unit and axis_type resolution in map_x_encoding without
3271
+ mutating any encoding state. Returns None when the trigger does not fire
3272
+ (temporal escape hatch, non-bucketed unit, or no temporal data).
3273
+ """
3274
+ x_field = resolved_chart.x
3275
+ if not x_field or not data:
3276
+ return None
3277
+
3278
+ effective = resolved_chart.resolved_style
3279
+ ct_attr = "arc" if resolved_chart.chart_type == "pie" else resolved_chart.chart_type
3280
+ ct_style = getattr(effective, ct_attr, None)
3281
+ ct_axis = getattr(ct_style, "axis_x", None) if ct_style is not None else None
3282
+ merged_x = resolved_axis_style(effective, "axis_x", "nominal", ct_axis)
3283
+ authored_tu = merged_x.time_unit
3284
+ authored_axis_type = merged_x.type # None / "auto" / "ordinal" / "temporal"
3285
+
3286
+ # Temporal escape hatch: author forces temporal → trigger does not fire.
3287
+ if authored_axis_type == "temporal":
3288
+ return None
3289
+
3290
+ # time_unit: none → no bucketing, trigger does not fire
3291
+ if authored_tu == "none":
3292
+ return None
3293
+
3294
+ x_values = [row.get(x_field) for row in data if x_field in row]
3295
+ x_type_from_data = (
3296
+ infer_vega_type_from_data(data, x_field) if x_field in data[0] else "nominal"
3297
+ )
3298
+
3299
+ if authored_tu and authored_tu not in ("auto",):
3300
+ time_unit: str | None = authored_tu
3301
+ elif x_type_from_data == "temporal":
3302
+ time_unit = detect_time_unit(x_values)
3303
+ else:
3304
+ time_unit = None
3305
+
3306
+ if time_unit is None or time_unit not in BUCKETED_CALENDAR_UNITS:
3307
+ return None
3308
+
3309
+ # Author forced ordinal, or bucketed-calendar default → ordinal
3310
+ if authored_axis_type in (None, "auto", "ordinal"):
3311
+ return time_unit
3312
+
3313
+ return None
3314
+
3315
+
3316
+ def _gap_fill(
3317
+ resolved_chart: ResolvedChart,
3318
+ data: list[dict[str, Any]],
3319
+ ) -> list[dict[str, Any]]:
3320
+ """Gap-fill data for ordinal bucketed-time charts when the trigger fires.
3321
+
3322
+ Returns the original data list unchanged when the trigger does not fire
3323
+ (e.g. temporal escape hatch, non-bucketed unit, empty data).
3324
+ """
3325
+ if not data:
3326
+ return data
3327
+
3328
+ x_field = resolved_chart.x
3329
+ if not x_field:
3330
+ return data
3331
+
3332
+ time_unit = _resolve_ordinal_time_unit(resolved_chart, data)
3333
+ if time_unit is None:
3334
+ return data
3335
+
3336
+ effective = resolved_chart.resolved_style
3337
+ ct_attr = "arc" if resolved_chart.chart_type == "pie" else resolved_chart.chart_type
3338
+ ct_style = getattr(effective, ct_attr, None)
3339
+ ct_axis = getattr(ct_style, "axis_x", None) if ct_style is not None else None
3340
+ axis_style = resolved_axis_style(effective, "axis_x", "nominal", ct_axis)
3341
+ fill = axis_style.fill
3342
+
3343
+ color_field = effective_color_field(resolved_chart)
3344
+ dim_fields = [color_field] if color_field else []
3345
+
3346
+ return complete_ordinal_time_series(data, x_field, time_unit, dim_fields, fill)
3347
+
3348
+
3349
+ # ── Public API ────────────────────────────────────────────────────────
3350
+
3351
+
3352
+ def map_to_vega_lite(
3353
+ resolved_chart: ResolvedChart,
3354
+ data: list[dict[str, Any]],
3355
+ width: float | None = None,
3356
+ theme: str | None = None,
3357
+ custom_registry: CustomChartTypeRegistry | None = None,
3358
+ datasets: dict[str, list[dict[str, Any]]] | None = None,
3359
+ background: str | None = None,
3360
+ ) -> MappedChart:
3361
+ """Map a resolved Dataface chart to Vega-Lite-native concepts.
3362
+
3363
+ This is the single boundary between Dataface canonical chart semantics
3364
+ and Vega-Lite encoding. Every profiled chart family dispatches here;
3365
+ exception families (geo, kpi) bypass this with documented reasons.
3366
+
3367
+ Custom chart types registered in ``custom_registry`` are resolved to
3368
+ their underlying Vega-Lite mark and routed through the standard
3369
+ cartesian path with optional encoding overrides from the definition.
3370
+
3371
+ The returned MappedChart can be assembled into a spec mechanically.
3372
+ """
3373
+ chart_type = resolved_chart.chart_type
3374
+
3375
+ if chart_type == "layered":
3376
+ return _map_explicit_layered_chart(
3377
+ resolved_chart,
3378
+ data,
3379
+ width,
3380
+ theme,
3381
+ datasets=datasets,
3382
+ )
3383
+
3384
+ if isinstance(resolved_chart.y, list):
3385
+ return _map_layered_chart(resolved_chart, data, width, theme)
3386
+
3387
+ # ── Per-family dispatch (built-in types only) ────────────────────
3388
+ if chart_type == "histogram":
3389
+ return _map_histogram(resolved_chart, data)
3390
+ if chart_type == "boxplot":
3391
+ return _map_boxplot(resolved_chart, data)
3392
+ if chart_type in ("errorbar", "errorband"):
3393
+ return _map_error(resolved_chart, data)
3394
+ if chart_type in ("arc", "pie"):
3395
+ return _map_slice(resolved_chart, data)
3396
+ if chart_type in ("rect", "heatmap", "square"):
3397
+ return _map_rect(resolved_chart, data)
3398
+
3399
+ # Gap-fill: synthesize missing time buckets before encoding; only fires when
3400
+ # the ordinal bucketed-time trigger is active (see _gap_fill).
3401
+ filled_data = _gap_fill(resolved_chart, data)
3402
+ data_override = filled_data if filled_data is not data else None
3403
+
3404
+ if chart_type == "line":
3405
+ mapped_line = _map_line(
3406
+ resolved_chart, filled_data, width, theme, background=background
3407
+ )
3408
+ if data_override is not None:
3409
+ return replace(mapped_line, data_override=data_override)
3410
+ return mapped_line
3411
+ if chart_type == "area":
3412
+ mapped_area = _map_area(
3413
+ resolved_chart, filled_data, width, theme, background=background
3414
+ )
3415
+ if data_override is not None:
3416
+ return replace(mapped_area, data_override=data_override)
3417
+ return mapped_area
3418
+
3419
+ # ── Standard cartesian path (built-in + custom types) ────────────
3420
+ mark = _map_mark(resolved_chart, custom_registry)
3421
+ encoding = _build_standard_encoding(resolved_chart, filled_data, theme)
3422
+
3423
+ # ── Custom type encoding overrides ───────────────────────────────
3424
+ custom_defn = custom_registry.get(chart_type) if custom_registry else None
3425
+ if custom_defn and custom_defn.encoding_overrides:
3426
+ for channel, overrides in custom_defn.encoding_overrides.items():
3427
+ if channel in encoding and isinstance(encoding[channel], dict):
3428
+ encoding[channel].update(overrides)
3429
+ else:
3430
+ encoding[channel] = overrides
3431
+
3432
+ z_transforms = _apply_stacked_bar_z_order(resolved_chart, encoding, filled_data)
3433
+ return MappedChart(
3434
+ mark=mark,
3435
+ encoding=encoding,
3436
+ data_override=data_override,
3437
+ transform=z_transforms,
3438
+ )