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,2645 @@
1
+ """Mechanical Vega-Lite assembly for resolved charts.
2
+
3
+ This module is the thin emitter layer. It consumes profile-mapped chart
4
+ state from ``profile.py`` and assembles the final Vega-Lite spec dict.
5
+ All Dataface/Vega-Lite divergence logic (type renames, channel encoding
6
+ construction, orientation transforms, sort mapping, bar axis defaults)
7
+ lives in ``profile.py``, not here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import copy
14
+ import functools
15
+ import json
16
+ import logging
17
+ import re
18
+ import statistics
19
+ from collections import defaultdict
20
+ from typing import TYPE_CHECKING, Any, Literal, cast
21
+
22
+ from dataface.core.compile.chart_resolved import (
23
+ ResolvedChart,
24
+ effective_color_field,
25
+ is_grouped_bar,
26
+ )
27
+ from dataface.core.compile.config import (
28
+ get_config,
29
+ get_default_theme_name,
30
+ get_theme_dict,
31
+ )
32
+ from dataface.core.compile.data_table_attachment import (
33
+ attach_data_table,
34
+ data_table_strip_height,
35
+ resolve_effective_data_table_style,
36
+ validate_data_table_against_data,
37
+ )
38
+ from dataface.core.compile.models.chart.authored import (
39
+ ChartDataTableAggregate,
40
+ ChartDataTablePerSeries,
41
+ )
42
+ from dataface.core.compile.models.style.compiled import (
43
+ EndpointLabelsConfig,
44
+ font_weight_as_css,
45
+ )
46
+ from dataface.core.compile.models.style.merged import (
47
+ MergedChartsStyle,
48
+ style_to_vega_lite,
49
+ )
50
+ from dataface.core.compile.palette import resolve_dark_companion_stops
51
+ from dataface.core.compile.vega_config import compile_effective_vega_config
52
+ from dataface.core.compile.vega_lite.validation import (
53
+ validate_top_level_spec,
54
+ )
55
+ from dataface.core.render.chart.geo import _generate_map_spec, _generate_point_map_spec
56
+ from dataface.core.render.chart.pipeline import (
57
+ count_horizontal_bar_categories,
58
+ min_height_for_horizontal_bar_categories,
59
+ )
60
+ from dataface.core.render.chart.presentation import (
61
+ overlay_merge,
62
+ to_plain_dict,
63
+ )
64
+ from dataface.core.render.chart.profile import (
65
+ CHART_TYPE_MAP,
66
+ _resolve_orient_auto,
67
+ map_to_vega_lite,
68
+ )
69
+ from dataface.core.render.chart.spec_builders import (
70
+ bump_padding_bottom,
71
+ bump_padding_top,
72
+ new_chart_spec,
73
+ set_chart_title,
74
+ )
75
+ from dataface.core.render.chart.tick_values import stacked_bar_totals_max
76
+ from dataface.core.render.chart.time_unit_detect import (
77
+ normalize_labeled_temporal,
78
+ opens_label_period,
79
+ resolve_label_time_unit,
80
+ )
81
+ from dataface.core.render.chart.title_overflow import apply_title_overflow_to_spec
82
+ from dataface.core.render.chart.type_inference import is_lex_sortable_date_like
83
+ from dataface.core.render.chart.validation import validate_preaggregated_data
84
+ from dataface.core.render.chart.vega_lite_types import (
85
+ _generate_arc_spec,
86
+ _generate_boxplot_spec,
87
+ _generate_error_spec,
88
+ _generate_histogram_spec,
89
+ _generate_layered_spec,
90
+ _generate_rect_spec,
91
+ )
92
+ from dataface.core.render.errors import ChartDataError, UnknownChartType
93
+ from dataface.core.render.font_measurement import get_font_measurer
94
+ from dataface.core.render.format_utils import format_value
95
+ from dataface.core.render.utils import normalize_data_types
96
+
97
+ if TYPE_CHECKING:
98
+ from dataface.core.compile.custom_chart_types import CustomChartTypeRegistry
99
+ from dataface.core.compile.models.chart.authored import ChartDataTable
100
+ from dataface.core.compile.models.style.compiled import DataTableStyle
101
+ from dataface.core.compile.models.style.merged import MergedStyle
102
+
103
+ logger = logging.getLogger(__name__)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Click interactivity helpers
108
+ # ---------------------------------------------------------------------------
109
+
110
+ _HREF_PLACEHOLDER = re.compile(r"\{\{\s*(x|y|color|theta)\s*\}\}")
111
+
112
+ # Sentinel prefix used so vl_convert doesn't mangle relative/query-string URLs.
113
+ # vl_convert resolves relative URLs against its own base URL, so we use an
114
+ # absolute URL with a known prefix that we strip in SVG post-processing.
115
+ # ".invalid" is an IANA-reserved TLD that will never resolve (RFC 6761).
116
+ _HREF_SENTINEL = "http://dft.invalid"
117
+
118
+ # Internal field name used for the calculate transform output
119
+ _HREF_FIELD = "__df_href__"
120
+
121
+
122
+ def _channel_field(resolved: ResolvedChart, channel: str) -> str | None:
123
+ """Return the data field name for a given encoding channel."""
124
+ if channel == "x":
125
+ return resolved.x
126
+ if channel == "y":
127
+ y = resolved.y
128
+ return y[0] if isinstance(y, list) else y
129
+ if channel == "color":
130
+ return effective_color_field(resolved)
131
+ if channel == "theta":
132
+ return resolved.theta
133
+ return None
134
+
135
+
136
+ def _build_href_calc_expr(
137
+ template: str, resolved: ResolvedChart, spec: dict[str, Any]
138
+ ) -> str:
139
+ """Build a Vega calculate expression for the href template.
140
+
141
+ ``{{ x }}`` → ``'' + datum['month']``
142
+
143
+ For temporal fields, Vega coerces datum values to millisecond timestamps
144
+ before calculate expressions run. ``'' + datum['day']`` therefore
145
+ produces a number like ``1775088000000`` rather than ``'2026-04-23'``.
146
+ When *spec* is provided we inspect the compiled encoding to detect
147
+ temporal fields and use ``timeFormat(datum['field'], '%Y-%m-%d')``
148
+ instead so the resulting URL params are ISO date strings.
149
+
150
+ Relative URLs (starting with ``?`` or ``/``) are prefixed with the
151
+ sentinel so vl_convert doesn't mangle them during SVG rendering.
152
+ The sentinel is stripped during SVG post-processing.
153
+ """
154
+ # Build a set of field names whose Vega-Lite encoding type is "temporal"
155
+ temporal_fields: set[str] = set()
156
+ for enc_val in spec.get("encoding", {}).values():
157
+ if isinstance(enc_val, dict) and enc_val.get("type") == "temporal":
158
+ field_name = enc_val.get("field")
159
+ if field_name:
160
+ temporal_fields.add(field_name)
161
+
162
+ parts = _HREF_PLACEHOLDER.split(template)
163
+ channels_found = _HREF_PLACEHOLDER.findall(template)
164
+
165
+ # parts alternates: literal, channel, literal, channel, ...
166
+ expr_parts: list[str] = []
167
+ for i, part in enumerate(parts):
168
+ if i % 2 == 0:
169
+ if part:
170
+ expr_parts.append(
171
+ "'" + part.replace("\\", "\\\\").replace("'", "\\'") + "'"
172
+ )
173
+ else:
174
+ channel = channels_found[i // 2]
175
+ field = _channel_field(resolved, channel)
176
+ if field is None:
177
+ raise ValueError(
178
+ f"link template references channel '{{{{ {channel} }}}}' but chart "
179
+ f"has no '{channel}' encoding assigned"
180
+ )
181
+ safe = field.replace("\\", "\\\\").replace("'", "\\'")
182
+ if field in temporal_fields:
183
+ # Vega coerces temporal datum values to ms timestamps; use
184
+ # timeFormat to get a URL-safe ISO date string instead.
185
+ expr_parts.append(f"timeFormat(datum['{safe}'], '%Y-%m-%d')")
186
+ else:
187
+ expr_parts.append(f"'' + datum['{safe}']")
188
+
189
+ expr = " + ".join(expr_parts) if expr_parts else "''"
190
+
191
+ # Prefix relative/query-string URLs with sentinel to prevent vl_convert mangling
192
+ if template.startswith("?") or template.startswith("/"):
193
+ return f"'{_HREF_SENTINEL}' + {expr}"
194
+ return expr
195
+
196
+
197
+ def _apply_click_interactivity(
198
+ spec: dict[str, Any], resolved: ResolvedChart
199
+ ) -> dict[str, Any]:
200
+ """Add href encoding to a Vega-Lite spec for href interactivity.
201
+
202
+ Uses a calculate transform + field encoding so vl_convert renders real
203
+ SVG ``<a>`` elements. The sentinel prefix on relative URLs is stripped
204
+ during SVG post-processing (see converters/chart.py).
205
+ """
206
+ if not resolved.link:
207
+ return spec
208
+
209
+ calc_expr = _build_href_calc_expr(resolved.link, resolved, spec=spec)
210
+ result = dict(spec)
211
+
212
+ transforms = list(result.get("transform") or [])
213
+ transforms.append({"calculate": calc_expr, "as": _HREF_FIELD})
214
+ result["transform"] = transforms
215
+
216
+ enc = dict(result.get("encoding", {}))
217
+ enc["href"] = {
218
+ "field": _HREF_FIELD,
219
+ "type": "nominal",
220
+ } # Vega-Lite wire name; unrelated to Dataface's authored `link:`
221
+ result["encoding"] = enc
222
+ return result
223
+
224
+
225
+ def _inject_legend_toggle_param(
226
+ spec: dict[str, Any],
227
+ resolved_chart: ResolvedChart,
228
+ resolved_chart_style: MergedChartsStyle,
229
+ ) -> dict[str, Any]:
230
+ """Inject a Vega-Lite point-selection param bound to the legend for click-to-mute.
231
+
232
+ Skipped when the spec has no top-level encoding (hconcat/vconcat wrappers from the
233
+ endpoint-label path) or no visible color legend.
234
+ """
235
+ if not resolved_chart_style.legend.interactive_legend:
236
+ return spec
237
+ color_field = effective_color_field(resolved_chart)
238
+ if color_field is None:
239
+ return spec
240
+ enc = spec.get("encoding")
241
+ if not isinstance(enc, dict):
242
+ return spec
243
+ color_enc = enc.get("color")
244
+ if not isinstance(color_enc, dict):
245
+ return spec
246
+ if "legend" in color_enc and color_enc["legend"] is None:
247
+ return spec
248
+
249
+ field_lit = json.dumps(color_field)
250
+ toggle_expr = (
251
+ f"event.shiftKey"
252
+ f" || indata('dft_legend_store', {field_lit}, datum[{field_lit}])"
253
+ )
254
+ opacity_cond = {
255
+ "condition": {"param": "dft_legend", "value": 1},
256
+ "value": 0.2,
257
+ }
258
+ dft_param = {
259
+ "name": "dft_legend",
260
+ "select": {"type": "point", "fields": [color_field], "toggle": toggle_expr},
261
+ "bind": "legend",
262
+ }
263
+
264
+ def _has_dft(params: list[Any] | None) -> bool:
265
+ return any(
266
+ isinstance(p, dict) and p.get("name") == "dft_legend"
267
+ for p in (params or [])
268
+ )
269
+
270
+ result = dict(spec)
271
+ layers = result.get("layer")
272
+ # In a layered spec, ``params`` at the top level produces a duplicate
273
+ # ``dft_legend_tuple`` Vega signal during compile (one per layer that
274
+ # inherits the param). Anchor the param inside the first data-bearing
275
+ # layer instead — opacity conditions in sibling layers reference it by
276
+ # name and Vega only registers one signal.
277
+ if isinstance(layers, list):
278
+ new_layers: list[dict[str, Any]] = []
279
+ param_anchored = False
280
+ for layer in layers:
281
+ if not isinstance(layer, dict) or "data" in layer:
282
+ new_layers.append(layer)
283
+ continue
284
+ mark = layer.get("mark") or {}
285
+ if isinstance(mark, dict) and mark.get("opacity") == 0:
286
+ # Invisible overlay layers (tooltip hit-target points) must not
287
+ # receive encoding-level opacity: it overrides mark.opacity=0 and
288
+ # makes the points visible when the legend selection is empty.
289
+ new_layers.append(layer)
290
+ continue
291
+ layer_enc = layer.get("encoding") or {}
292
+ updated = dict(layer)
293
+ if "opacity" not in layer_enc:
294
+ updated["encoding"] = {**layer_enc, "opacity": opacity_cond}
295
+ if not param_anchored:
296
+ if _has_dft(layer.get("params")):
297
+ return spec # idempotent
298
+ updated["params"] = [*(layer.get("params") or []), dft_param]
299
+ param_anchored = True
300
+ new_layers.append(updated)
301
+ if not param_anchored:
302
+ return spec # only special layers — nothing to bind opacity to
303
+ result["layer"] = new_layers
304
+ else:
305
+ if _has_dft(result.get("params")):
306
+ return spec # idempotent
307
+ result["params"] = [*(result.get("params") or []), dft_param]
308
+ if "opacity" not in enc:
309
+ result["encoding"] = {**enc, "opacity": opacity_cond}
310
+ return result
311
+
312
+
313
+ def build_base_spec(
314
+ data: list[dict[str, Any]],
315
+ width: float | None = None,
316
+ height: float | None = None,
317
+ ) -> dict[str, Any]:
318
+ """Create the base Vega-Lite shell for resolved standard charts."""
319
+ spec = new_chart_spec(data)
320
+ if width is not None and width > 0:
321
+ spec["width"] = width
322
+ if height is not None and height > 0:
323
+ spec["height"] = height
324
+ return spec
325
+
326
+
327
+ def _vl_dict_subtract_equal(
328
+ full: dict[str, Any], default: dict[str, Any]
329
+ ) -> dict[str, Any]:
330
+ """Return only the keys in `full` that differ from `default` (nested)."""
331
+ result: dict[str, Any] = {}
332
+ for k, v in full.items():
333
+ if k not in default:
334
+ result[k] = v
335
+ elif isinstance(v, dict) and isinstance(default[k], dict):
336
+ diff = _vl_dict_subtract_equal(v, default[k])
337
+ if diff:
338
+ result[k] = diff
339
+ elif v != default[k]:
340
+ result[k] = v
341
+ return result
342
+
343
+
344
+ @functools.lru_cache(maxsize=1)
345
+ def _default_vl_overlay() -> dict[str, Any]:
346
+ """VL config dict for the theme-applied YAML baseline style.
347
+
348
+ Used as baseline in _resolved_style_vega_overlay to emit only
349
+ non-default values — so cascade-filled defaults don't clobber the
350
+ VL theme colors already baked into compile_effective_vega_config.
351
+ The baseline matches what rendered charts see, so only genuine
352
+ per-chart overrides are emitted as deltas.
353
+ """
354
+ from dataface.core.compile.models.style.merged import resolve_style
355
+
356
+ return style_to_vega_lite(resolve_style(get_config().style).charts).model_dump(
357
+ exclude_none=True
358
+ )
359
+
360
+
361
+ def _resolved_style_vega_overlay(style: MergedChartsStyle) -> dict[str, Any]:
362
+ """Convert a MergedChartsStyle to a Vega-Lite config dict for overlay.
363
+
364
+ Only emits values that DIFFER from the bare cascade default so that
365
+ VL theme colors (from compile_effective_vega_config) are not clobbered
366
+ by cascade-filled root defaults.
367
+ """
368
+ vlc = style_to_vega_lite(style)
369
+ full = vlc.model_dump(exclude_none=True)
370
+ return _vl_dict_subtract_equal(full, _default_vl_overlay())
371
+
372
+
373
+ def apply_presentation_defaults(
374
+ spec: dict[str, Any],
375
+ theme_name: str | None,
376
+ resolved_chart_style: MergedChartsStyle,
377
+ *,
378
+ effective_vega_config: dict[str, Any] | None = None,
379
+ width_aware_title: dict[str, Any] | None = None,
380
+ face_background_overlay: str | None = None,
381
+ ) -> dict[str, Any]:
382
+ """Apply compiled presentation defaults to an emitted Vega-Lite spec once.
383
+
384
+ When callers provide ``effective_vega_config``, render uses that compiled
385
+ base config directly and only overlays authored chart config plus the
386
+ effective chart style view.
387
+
388
+ When omitted, render falls back to compiling defaults from the unified theme.
389
+
390
+ ``width_aware_title`` splits across two priority tiers: its width-driven
391
+ ``font`` (family) applies *after* the style overlay so the
392
+ narrow→body / wide→title family choice wins over the editorial title-slot
393
+ delta; its ``fontSize`` / ``fontWeight`` apply *before* the style overlay
394
+ so an authored ``style.title.font.size`` or ``font.weight`` still wins.
395
+ The width-aware family itself already incorporates face-local and
396
+ chart-local family patches (via ``chart_title_spec`` reading the
397
+ resolved chart style), so authored family flows through at medium/wide
398
+ tiers; only narrow-tier legibility demotion bypasses it.
399
+ """
400
+ result_spec = copy.deepcopy(spec)
401
+ if "config" not in result_spec:
402
+ result_spec["config"] = {}
403
+ elif not isinstance(result_spec["config"], dict):
404
+ raise TypeError("Chart config must be a dictionary")
405
+ authored_config = copy.deepcopy(result_spec["config"])
406
+
407
+ if effective_vega_config: # falsy (None or {}) → compile from theme defaults
408
+ compiled = copy.deepcopy(effective_vega_config)
409
+ else:
410
+ compiled = compile_effective_vega_config(theme=theme_name)
411
+
412
+ # width_aware_title carries three keys: font (family), fontSize, fontWeight.
413
+ # They split between two priority tiers because they answer different
414
+ # questions:
415
+ # - fontSize / fontWeight are width-aware defaults. The style overlay
416
+ # must win when a chart author writes `style.title.font.size: 30` or
417
+ # `style.title.font.weight: 700` — apply them BEFORE the overlay.
418
+ # - font (family) is a width-driven decision that wins by design:
419
+ # narrow widths demote to the body family for legibility, regardless
420
+ # of theme. The width-aware family already incorporates any authored
421
+ # `style.title.font.family` (via chart_title_spec reading resolved_-
422
+ # chart_style at medium/wide tiers), so authored family flows through
423
+ # too. Apply font AFTER the overlay so the editorial title-slot delta
424
+ # can't clobber the narrow→body demotion.
425
+ if width_aware_title:
426
+ compiled.setdefault("title", {})
427
+ title_defaults = {k: v for k, v in width_aware_title.items() if k != "font"}
428
+ if title_defaults:
429
+ compiled["title"] = overlay_merge(compiled["title"], title_defaults)
430
+
431
+ style_overlay = _resolved_style_vega_overlay(resolved_chart_style)
432
+ if style_overlay:
433
+ compiled = overlay_merge(compiled, style_overlay)
434
+
435
+ if width_aware_title and "font" in width_aware_title:
436
+ compiled.setdefault("title", {})
437
+ compiled["title"] = overlay_merge(
438
+ compiled["title"], {"font": width_aware_title["font"]}
439
+ )
440
+
441
+ result_spec["config"] = compiled
442
+
443
+ effective_theme_name = (
444
+ get_default_theme_name() if theme_name in (None, "default") else theme_name
445
+ )
446
+ vega_theme_config = get_theme_dict(effective_theme_name)
447
+ preserve_transparent_background = (
448
+ result_spec.get("background") is None and "projection" in result_spec
449
+ )
450
+ if not preserve_transparent_background:
451
+ # Spec root background precedence: face's resolved background (when
452
+ # threaded through) > unified compiled theme > old-format VL theme.
453
+ # The face background is the source of truth — it already accounts for
454
+ # theme + face-local style overrides, so charts inside a face never
455
+ # disagree with the face's actual paper colour.
456
+ root_bg = (
457
+ face_background_overlay
458
+ if face_background_overlay is not None
459
+ else (
460
+ compiled.get("background")
461
+ if not vega_theme_config
462
+ else vega_theme_config.get("background")
463
+ )
464
+ )
465
+ if root_bg is not None:
466
+ result_spec["background"] = root_bg
467
+
468
+ # Chart-local background override takes priority over theme at spec root level.
469
+ # Only non-None when set by ChartStylePatch.background (chart-local override).
470
+ if resolved_chart_style.background is not None:
471
+ result_spec["background"] = resolved_chart_style.background
472
+
473
+ result_spec["config"] = overlay_merge(result_spec["config"], authored_config)
474
+ return result_spec
475
+
476
+
477
+ def build_layered_series_spec(
478
+ resolved_chart: ResolvedChart,
479
+ data: list[dict[str, Any]],
480
+ width: float | None = None,
481
+ height: float | None = None,
482
+ theme: str | None = None,
483
+ datasets: dict[str, list[dict[str, Any]]] | None = None,
484
+ ) -> dict[str, Any]:
485
+ """Build the explicit layered-series path for multi-metric charts.
486
+
487
+ Layered profiled charts use the same ``MappedChart`` contract as single-series
488
+ profiled charts so the renderer never re-derives chart semantics.
489
+ """
490
+ mapped = map_to_vega_lite(resolved_chart, data, width, theme, datasets=datasets)
491
+ return _generate_layered_spec(mapped, resolved_chart, data, width, height)
492
+
493
+
494
+ def _apply_projection_override(
495
+ spec: dict[str, Any], resolved_chart: ResolvedChart
496
+ ) -> dict[str, Any]:
497
+ """Apply the authored projection field onto the emitted spec."""
498
+ authored_projection = to_plain_dict(resolved_chart.projection)
499
+ if isinstance(authored_projection, str):
500
+ authored_projection = {"type": authored_projection}
501
+ if not authored_projection:
502
+ return spec
503
+
504
+ result = copy.deepcopy(spec)
505
+ existing_projection = result.get("projection")
506
+ if isinstance(existing_projection, dict) and isinstance(authored_projection, dict):
507
+ result["projection"] = overlay_merge(existing_projection, authored_projection)
508
+ else:
509
+ result["projection"] = authored_projection
510
+ return result
511
+
512
+
513
+ def _finalize_standard_spec(
514
+ spec: dict[str, Any],
515
+ theme: str | None,
516
+ resolved_chart_style: MergedChartsStyle,
517
+ width: float | None = None,
518
+ face_background_overlay: str | None = None,
519
+ face_level: int = 1,
520
+ effective_vega_config: dict[str, Any] | None = None,
521
+ ) -> dict[str, Any]:
522
+ """Apply presentation defaults and validate the emitted Vega-Lite spec.
523
+
524
+ Presentation defaults are applied first so that the compiled Vega config
525
+ (including ``config.title.fontSize`` from the theme) is available when
526
+ ``apply_title_overflow_to_spec`` computes character-level line breaks.
527
+
528
+ Args:
529
+ face_level: Heading level of the parent face (root=1, nested=2, …).
530
+ Chart title uses face_level + 1.
531
+ """
532
+ from dataface.core.compile.typography import chart_title_spec
533
+ from dataface.core.render.font_support import (
534
+ INTER_FONT_FAMILY,
535
+ INTER_VARIABLE_FONT_FAMILY,
536
+ )
537
+
538
+ width_aware_title: dict[str, Any] | None = None
539
+ title_font_size: float | None = None
540
+ title_font_family: str | None = None
541
+ if width is not None and width > 0:
542
+ font_size, font_weight, font_family = chart_title_spec(
543
+ width, level=face_level + 1, resolved_chart_style=resolved_chart_style
544
+ )
545
+ title_font_size = float(font_size)
546
+ # vl-convert registers InterVariable.ttf as "Inter Variable", not "Inter".
547
+ # Use the registered name so Vega renders with the correct font instead of
548
+ # falling back to a system sans-serif. The measurer resolves both names to
549
+ # the same TTF (see get_font_measurer), so pass the vl name on both sides to
550
+ # keep the measurer/renderer font identity tight.
551
+ title_font_family = (
552
+ INTER_VARIABLE_FONT_FAMILY
553
+ if font_family == INTER_FONT_FAMILY
554
+ else font_family
555
+ )
556
+ width_aware_title = {
557
+ "font": title_font_family,
558
+ "fontSize": font_size,
559
+ "fontWeight": font_weight,
560
+ }
561
+ spec_with_config = apply_presentation_defaults(
562
+ spec,
563
+ theme,
564
+ resolved_chart_style,
565
+ effective_vega_config=effective_vega_config,
566
+ width_aware_title=width_aware_title,
567
+ face_background_overlay=face_background_overlay,
568
+ )
569
+ apply_title_overflow_to_spec(
570
+ spec_with_config,
571
+ resolved_chart_style.title,
572
+ title_font_size=title_font_size,
573
+ title_font_family=title_font_family,
574
+ )
575
+ return validate_top_level_spec(spec_with_config)
576
+
577
+
578
+ _LABEL_Y_ALIAS = "__label_y"
579
+ _LABEL_X_ALIAS = "__label_x"
580
+ _LABEL_DODGE_ALIAS = "__dodge_row"
581
+
582
+ # Horizontal stacked bar label rail: maximum number of times a colliding
583
+ # label may be lifted above the base row. The leftmost label always sits
584
+ # at row 0; each rightward neighbor that would intersect a previously
585
+ # placed label's bbox lifts by one line-height. If the cap is hit, the
586
+ # next collision is placed at the cap row anyway — the alternative
587
+ # (dropping labels or raising) makes the chart misread silently.
588
+ _HORIZONTAL_LABEL_RAIL_MAX_DODGE = 3
589
+
590
+ # Pixel padding between adjacent label bounding boxes, used by the
591
+ # horizontal-rail dodge resolver to decide whether two labels can sit at
592
+ # the same dodge row. Mirrors the line/area pane's pixel-gap design;
593
+ # tuned so labels visually clear at body-font sizes.
594
+ _HORIZONTAL_LABEL_RAIL_X_GAP_PX = 4.0
595
+
596
+
597
+ def _dark_companion_stops(
598
+ data: list[dict[str, Any]],
599
+ series_field: str,
600
+ palette: list[str],
601
+ ) -> tuple[list[str], list[str]]:
602
+ """Endpoint-label helper: derive the color-domain order + dark companions.
603
+
604
+ Wraps :func:`resolve_dark_companion_stops` for the endpoint-label path,
605
+ which needs the alphabetised color domain alongside the resolved dark
606
+ stops. Vega-lite sorts nominal scale domains alphabetically, so the
607
+ chart pane maps ``palette[i]`` to the i-th series in that order — the
608
+ label pane must mirror it.
609
+
610
+ Returns ``(color_domain_order, dark_stops)`` indexed in lockstep.
611
+ """
612
+ distinct: set[str] = set()
613
+ for row in data:
614
+ s = row.get(series_field)
615
+ if s is not None:
616
+ distinct.add(str(s))
617
+ color_domain_order = sorted(distinct)
618
+ n = len(color_domain_order) or 1
619
+ emitted_colors = palette[:n]
620
+ dark_stops = resolve_dark_companion_stops(emitted_colors)
621
+ return color_domain_order, dark_stops
622
+
623
+
624
+ def _label_mark_font(
625
+ resolved_chart_style: MergedChartsStyle,
626
+ ) -> tuple[dict[str, Any], str, float]:
627
+ """Resolve the series-label font triple for a label-pane text mark.
628
+
629
+ Returns ``(mark_props, font_family, font_size)``: ``mark_props`` is a
630
+ dict ready to splice into a ``mark`` (carries ``fontSize``, ``font``,
631
+ and ``fontWeight`` mapped through ``font_weight_as_css``);
632
+ ``font_family`` and ``font_size`` are surfaced so callers can also use
633
+ them with the font measurer to size the pane.
634
+
635
+ All three font fields assert non-None — the cascade fills family/
636
+ weight from ``charts.font`` and size from the explicit theme value.
637
+ Silent fallback would mask the same "cascade dropped my override"
638
+ class of bug the font primitive is built to prevent.
639
+ """
640
+ label_font_size = resolved_chart_style.series_label.font.size
641
+ assert label_font_size is not None, (
642
+ "MergedChartsStyle.series_label.font.size is None — theme cascade did "
643
+ "not fill it; fix the theme rather than defaulting in the renderer"
644
+ )
645
+ label_font_family = resolved_chart_style.series_label.font.family
646
+ assert label_font_family is not None, (
647
+ "MergedChartsStyle.series_label.font.family is None — _apply_cascade "
648
+ "did not fill it from charts.font; fix the theme rather than "
649
+ "defaulting in the renderer"
650
+ )
651
+ label_font_weight = resolved_chart_style.series_label.font.weight
652
+ assert label_font_weight is not None, (
653
+ "MergedChartsStyle.series_label.font.weight is None — _apply_cascade "
654
+ "did not fill it from charts.font; fix the theme rather than "
655
+ "defaulting in the renderer"
656
+ )
657
+ return (
658
+ {
659
+ "fontSize": label_font_size,
660
+ "font": label_font_family,
661
+ "fontWeight": font_weight_as_css(label_font_weight),
662
+ },
663
+ label_font_family,
664
+ float(label_font_size),
665
+ )
666
+
667
+
668
+ # Font-size multiplier matching standard line-height for body text. The
669
+ # value applies as the minimum-gap between adjacent label y-centers, in
670
+ # pixels-per-point-of-font-size. At 1.0 two 14pt labels sit 14px apart
671
+ # — bounding rects just touch, but lowercase letters (which mostly live
672
+ # in the x-height, ~0.5× font size) have clear visual separation. 1.4
673
+ # was tried earlier and read as too airy for tight series clusters; 0.7
674
+ # let descender-on-ascender pairs visually overlap. 1.0 is the middle
675
+ # call: bounding rects kiss, lowercase reads cleanly, descenders on the
676
+ # rare ascender pair touch but don't cross.
677
+ _LABEL_LINE_HEIGHT_MULTIPLIER = 1.3
678
+
679
+ # Plot area is smaller than the chart's outer height (title + subtitle +
680
+ # axis labels + padding eat into it). 0.85 is a coarse approximation that
681
+ # errs on the safe side — slightly under-estimating plot height inflates
682
+ # the data-units gap, so labels end up a bit further apart than the pure
683
+ # math would call for, never closer.
684
+ _PLOT_AREA_FRACTION = 0.85
685
+
686
+
687
+ def _extract_y_domain(
688
+ spec: dict[str, Any],
689
+ data: list[dict[str, Any]],
690
+ y_field: str,
691
+ ) -> tuple[float, float]:
692
+ """Return (y_min, y_max) for the chart's y scale.
693
+
694
+ Prefer explicit bounds from the finalized spec (what vega-lite actually
695
+ maps to pixels) in this order:
696
+ 1. ``encoding.y.scale.domain`` list — exact two-element bound.
697
+ 2. ``encoding.y.scale.domainMin`` + ``domainMax`` both present — stacked
698
+ bars set domainMax; bar/area charts set domainMin via tick pinning.
699
+ Falls back to raw data range when the chart leaves all bounds implicit.
700
+ """
701
+ enc = spec.get("encoding")
702
+ if isinstance(enc, dict):
703
+ y_enc = enc.get("y")
704
+ if isinstance(y_enc, dict):
705
+ scale = y_enc.get("scale")
706
+ if isinstance(scale, dict):
707
+ domain = scale.get("domain")
708
+ if isinstance(domain, list) and len(domain) == 2:
709
+ try:
710
+ lo, hi = float(domain[0]), float(domain[1])
711
+ if hi >= lo:
712
+ return lo, hi
713
+ except (TypeError, ValueError):
714
+ pass
715
+ d_min = scale.get("domainMin")
716
+ d_max = scale.get("domainMax")
717
+ if d_min is not None and d_max is not None:
718
+ try:
719
+ lo, hi = float(d_min), float(d_max)
720
+ if hi >= lo:
721
+ return lo, hi
722
+ except (TypeError, ValueError):
723
+ pass
724
+ ys = [
725
+ float(row[y_field])
726
+ for row in data
727
+ if y_field in row and row[y_field] is not None
728
+ ]
729
+ if ys:
730
+ return float(min(ys)), float(max(ys))
731
+ return 0.0, 1.0
732
+
733
+
734
+ def _resolve_endpoint_label_positions(
735
+ data: list[dict[str, Any]],
736
+ x_field: str,
737
+ y_field: str,
738
+ series_field: str,
739
+ min_data_gap: float,
740
+ y_domain_min: float,
741
+ y_domain_max: float,
742
+ ) -> list[tuple[str, float]]:
743
+ """Compute (series, label_y) pairs anchored to line endpoints with
744
+ bidirectional greedy collision avoidance.
745
+
746
+ Algorithm:
747
+ 1. For each series take the row at the largest x — its y value is
748
+ the natural anchor.
749
+ 2. Compute the cluster centroid (mean of anchor y's). If the
750
+ centroid sits in the upper half of the y domain, cascade
751
+ *downward*: walk anchors top-to-bottom and push each one down
752
+ to ``previous − min_data_gap`` if it's too close. If the
753
+ centroid is in the lower half, cascade *upward* instead: walk
754
+ anchors bottom-to-top and push each one up by ``min_data_gap``.
755
+
756
+ The direction flip prevents labels from being shoved off the chart's
757
+ bottom (resp. top) when several series cluster against one edge of
758
+ the plot — the anchored end of the cascade is whichever one is
759
+ closer to its bound, so displacement always grows *into* the empty
760
+ half of the plot.
761
+
762
+ `min_data_gap` and the domain bounds are computed by the caller so
763
+ spacing stays fixed in *pixels* across charts of different y ranges.
764
+ Returns a list ordered top-to-bottom (descending y) regardless of
765
+ which cascade direction was used.
766
+ """
767
+ last_x: Any = None
768
+ for row in data:
769
+ v = row.get(x_field)
770
+ if v is None:
771
+ continue
772
+ if last_x is None or v > last_x:
773
+ last_x = v
774
+
775
+ last_per_series: dict[str, float] = {}
776
+ for row in data:
777
+ if row.get(x_field) != last_x:
778
+ continue
779
+ s = row.get(series_field)
780
+ y = row.get(y_field)
781
+ if s is None or y is None:
782
+ continue
783
+ last_per_series[str(s)] = float(y)
784
+
785
+ return _apply_label_cascade(
786
+ last_per_series,
787
+ min_data_gap=min_data_gap,
788
+ y_domain_min=y_domain_min,
789
+ y_domain_max=y_domain_max,
790
+ )
791
+
792
+
793
+ def _apply_label_cascade(
794
+ anchors: dict[str, float],
795
+ min_data_gap: float,
796
+ y_domain_min: float,
797
+ y_domain_max: float,
798
+ ) -> list[tuple[str, float]]:
799
+ """Bidirectional greedy collision-avoidance over an anchor map.
800
+
801
+ Shared by the line/area and bar resolvers — both produce a
802
+ ``{series: anchor_y}`` map and want the same nudge-on-collision pass.
803
+ Output is ordered top-to-bottom (descending y).
804
+ """
805
+ if not anchors:
806
+ return []
807
+
808
+ domain_mid = (y_domain_min + y_domain_max) / 2.0
809
+ anchor_mean = sum(anchors.values()) / len(anchors)
810
+
811
+ if anchor_mean >= domain_mid:
812
+ # Cluster sits in the upper half → pin the topmost anchor and
813
+ # cascade downward into the empty lower half.
814
+ items = sorted(anchors.items(), key=lambda kv: kv[1], reverse=True)
815
+ adjusted: list[tuple[str, float]] = []
816
+ for series, y in items:
817
+ if adjusted and adjusted[-1][1] - y < min_data_gap:
818
+ y = adjusted[-1][1] - min_data_gap
819
+ # Clamp before storing so subsequent iterations use the clamped
820
+ # position and out-of-domain values never reach the label pane
821
+ # data (which would expand the shared hconcat y-scale).
822
+ y = max(y_domain_min, y)
823
+ adjusted.append((series, y))
824
+ return adjusted
825
+
826
+ # Cluster sits in the lower half → pin the bottommost anchor and
827
+ # cascade upward into the empty upper half.
828
+ items = sorted(anchors.items(), key=lambda kv: kv[1])
829
+ upward: list[tuple[str, float]] = []
830
+ for series, y in items:
831
+ if upward and y - upward[-1][1] < min_data_gap:
832
+ y = upward[-1][1] + min_data_gap
833
+ y = min(y_domain_max, y)
834
+ upward.append((series, y))
835
+ # Caller expects descending-y order (top of plot first).
836
+ return list(reversed(upward))
837
+
838
+
839
+ def _resolve_bar_endpoint_label_positions(
840
+ data: list[dict[str, Any]],
841
+ x_field: str,
842
+ y_field: str,
843
+ series_field: str,
844
+ stack_mode: str | bool | None,
845
+ min_data_gap: float,
846
+ y_domain_min: float,
847
+ y_domain_max: float,
848
+ stack_order: str | None = None,
849
+ ) -> list[tuple[str, float]]:
850
+ """Compute (series, label_y) anchors for the bar family.
851
+
852
+ Per-stack-mode anchor:
853
+ - ``"zero"`` (or ``None`` — VL's bar default): segment midpoint of
854
+ each series in the rightmost x column. Cumulative bottom-up sum
855
+ in the order determined by ``stack_order``, matching
856
+ ``_apply_stacked_bar_z_order``'s pin so labels anchor over their
857
+ actual rendered segments.
858
+ - ``"normalize"``: same midpoint, expressed as a share of the
859
+ column's total so anchors land on the 0..1 normalized scale.
860
+ - ``False``: grouped/overlapping bars — anchor sits at each
861
+ series' bar top (the value itself).
862
+
863
+ ``stack_order`` mirrors the same knob as ``_apply_stacked_bar_z_order``:
864
+ - ``None`` / ``"value"``: largest-sum series at baseline (default).
865
+ - ``"alphabetical"``: alpha-ascending series at baseline.
866
+ - ``"data"``: caller should not be computing midpoints for data order;
867
+ treated as value order here (SQL order is not reproduced at render time).
868
+
869
+ Cascade pass shared with the line/area resolver via
870
+ ``_apply_label_cascade``.
871
+
872
+ Raises ``ChartDataError`` when the chart's stack mode falls outside the
873
+ supported set (``"center"``) or when values are negative — both produce
874
+ silently wrong anchor positions on top of correctly-rendered bars and
875
+ violate the validate-and-error-fast non-negotiable. The caller in
876
+ ``_build_endpoint_label_pane`` already collapses ``stack: True`` to
877
+ ``"zero"``, so this resolver sees one canonical mode per call.
878
+ """
879
+ if stack_mode not in (None, False, "zero", "normalize"):
880
+ raise ChartDataError(
881
+ f"endpoint_labels: stack mode {stack_mode!r} is not supported on "
882
+ "the bar family — use 'zero', 'normalize', or stack: false. "
883
+ "(Diverging 'center' stacks would need signed segment-midpoint "
884
+ "anchors against a (-Σ/2, +Σ/2) domain; not yet implemented.)"
885
+ )
886
+
887
+ last_x: Any = None
888
+ for row in data:
889
+ v = row.get(x_field)
890
+ if v is None:
891
+ continue
892
+ if last_x is None or v > last_x:
893
+ last_x = v
894
+
895
+ values_at_last: dict[str, float] = {}
896
+ for row in data:
897
+ if row.get(x_field) != last_x:
898
+ continue
899
+ s = row.get(series_field)
900
+ y = row.get(y_field)
901
+ if s is None or y is None:
902
+ continue
903
+ values_at_last[str(s)] = float(y)
904
+ if not values_at_last:
905
+ return []
906
+
907
+ if stack_mode is not False and any(v < 0 for v in values_at_last.values()):
908
+ raise ChartDataError(
909
+ "endpoint_labels: negative values in the trailing x column are "
910
+ "not supported on stacked bar charts — VL's stacked domain spans "
911
+ "both signs and the cumulative-midpoint computation would land "
912
+ "labels off the segments. Set ``stack: false`` (grouped bars), "
913
+ "or filter/transform the data upstream."
914
+ )
915
+
916
+ # Series iteration order must match _apply_stacked_bar_z_order so label
917
+ # anchors land on the actual rendered segments. The first series in the
918
+ # iteration sits at cum_lower=0 (baseline); subsequent ones stack above.
919
+ # VL stacks by the global sum (joinaggregate transform on encoding.order),
920
+ # so we sort by global sum here too (not local per-bar values).
921
+ # Grouped bars (stack=False) overlap, no stacking order needed.
922
+ if stack_order == "alphabetical":
923
+ # Alpha-ascending: alphabetically-first series at baseline.
924
+ series_order = sorted(values_at_last)
925
+ elif stack_order == "data":
926
+ # VL builds its ordinal color domain in global first-encounter order across
927
+ # all data rows — not the order at last_x. Use the same traversal so label
928
+ # midpoints land on the right segments even when row order varies by x-group.
929
+ seen: dict[str, None] = {}
930
+ for row in data:
931
+ s = row.get(series_field)
932
+ if s is not None:
933
+ seen[str(s)] = None
934
+ series_order = [s for s in seen if s in values_at_last]
935
+ else:
936
+ # Default (None or "value"): globally-largest series at baseline.
937
+ # Stable secondary sort by name so ties are deterministic.
938
+ global_sums: dict[str, float] = {}
939
+ for row in data:
940
+ s = row.get(series_field)
941
+ y = row.get(y_field)
942
+ if s is None or y is None:
943
+ continue
944
+ key = str(s)
945
+ global_sums[key] = global_sums.get(key, 0.0) + float(y)
946
+ series_order = sorted(
947
+ values_at_last, key=lambda s: (-global_sums.get(s, 0.0), s)
948
+ )
949
+
950
+ if stack_mode is False:
951
+ anchors = {s: values_at_last[s] for s in series_order}
952
+ else:
953
+ total = sum(values_at_last[s] for s in series_order)
954
+ anchors = {}
955
+ cum_lower = 0.0
956
+ for s in series_order:
957
+ v = values_at_last[s]
958
+ mid = cum_lower + v / 2.0
959
+ if stack_mode == "normalize" and total > 0:
960
+ mid = mid / total
961
+ anchors[s] = mid
962
+ cum_lower += v
963
+
964
+ return _apply_label_cascade(
965
+ anchors,
966
+ min_data_gap=min_data_gap,
967
+ y_domain_min=y_domain_min,
968
+ y_domain_max=y_domain_max,
969
+ )
970
+
971
+
972
+ def _bar_stack_y_domain(
973
+ data: list[dict[str, Any]],
974
+ x_field: str,
975
+ y_field: str,
976
+ stack_mode: str | bool | None,
977
+ ) -> tuple[float, float]:
978
+ """Return the (min, max) y-domain that the bar chart actually renders.
979
+
980
+ ``_extract_y_domain`` reads the spec's explicit y-scale domain when
981
+ set; for stacked bar charts vega-lite computes the stacked domain at
982
+ render time and the spec leaves ``scale.domain`` unset, so falling
983
+ back to raw value min/max would put cascade decisions in the wrong
984
+ half of the chart. Compute the correct rendered domain here.
985
+ """
986
+ if stack_mode == "normalize":
987
+ return 0.0, 1.0
988
+ if stack_mode is False:
989
+ ys = [
990
+ float(row[y_field])
991
+ for row in data
992
+ if y_field in row and row[y_field] is not None
993
+ ]
994
+ if not ys:
995
+ return 0.0, 1.0
996
+ # Grouped/overlapping bars: VL's quantitative y-scale always
997
+ # includes 0; with negative values the domain spans both signs.
998
+ return min(0.0, min(ys)), max(0.0, max(ys))
999
+ # Stacked at zero (default for bar with color encoding).
1000
+ totals_max = stacked_bar_totals_max(data, x_field, y_field)
1001
+ return (0.0, totals_max) if totals_max is not None else (0.0, 1.0)
1002
+
1003
+
1004
+ def _label_pane_min_data_gap(
1005
+ resolved_chart_style: MergedChartsStyle,
1006
+ y_domain_min: float,
1007
+ y_domain_max: float,
1008
+ spec_height: float | None = None,
1009
+ ) -> float:
1010
+ """Convert the desired pixel gap between labels into data units.
1011
+
1012
+ The gap target is fixed in pixels — `series_label.font.size × line-height` —
1013
+ so a chart with a tall y range gets the same visual spacing as a short one.
1014
+ We scale that target into data units using the chart's y-domain range and pixel
1015
+ height, because the resolver works in data units (the label pane shares
1016
+ pane[0]'s y scale).
1017
+
1018
+ The label pane is pinned to ``spec_height`` (see ``_build_endpoint_label_pane``),
1019
+ so the shared hconcat y-scale always uses ``spec_height`` as its pixel range.
1020
+ When ``spec_height`` is unknown (None or 0) both panes default to
1021
+ ``view.continuousHeight``.
1022
+ """
1023
+ font_size = resolved_chart_style.series_label.font.size
1024
+ assert font_size is not None, (
1025
+ "MergedChartsStyle.series_label.font.size is None — theme cascade "
1026
+ "did not fill it; fix the theme rather than defaulting in the renderer"
1027
+ )
1028
+ y_range = (y_domain_max - y_domain_min) if y_domain_max > y_domain_min else 1.0
1029
+ continuous = resolved_chart_style.view.continuous_height
1030
+ # Label pane carries explicit height=spec_height (see _build_endpoint_label_pane),
1031
+ # so the shared hconcat y-scale always uses spec_height as its pixel range.
1032
+ # When spec_height is unknown, both panes default to view.continuousHeight.
1033
+ chart_height = (
1034
+ float(spec_height)
1035
+ if spec_height is not None and spec_height > 0
1036
+ else continuous
1037
+ )
1038
+ plot_height = chart_height * _PLOT_AREA_FRACTION
1039
+ min_pixel_gap = font_size * _LABEL_LINE_HEIGHT_MULTIPLIER
1040
+ return min_pixel_gap * (y_range / plot_height)
1041
+
1042
+
1043
+ def _resolve_horizontal_top_row_label_anchors(
1044
+ data: list[dict[str, Any]],
1045
+ x_field: str,
1046
+ y_field: str,
1047
+ series_field: str,
1048
+ stack_mode: str | bool | None,
1049
+ stack_order: str | None = None,
1050
+ ) -> dict[str, float]:
1051
+ """Return ``{series: x_midpoint}`` for the alphabetical-first row of a
1052
+ horizontal stacked bar chart.
1053
+
1054
+ The horizontal label rail names color segments inside a single
1055
+ "label-bearing" row at the top of the chart. The top row is the
1056
+ alphabetical-first value of the *post-swap-y* axis (which is the
1057
+ authored ``x_field`` — VL renders nominal-domain[0] at the top of a
1058
+ left-oriented categorical axis). Within that row, segments stack
1059
+ along the measure axis with ``_apply_stacked_bar_z_order``'s order pin
1060
+ (value-descending by default, or alphabetical when stack_order='alphabetical').
1061
+ Midpoints are computed cumulatively in the same order so each label
1062
+ anchors over its actual rendered segment.
1063
+
1064
+ For ``stack: normalize`` midpoints are expressed as shares of the
1065
+ top row's total (the chart pane is pinned to the [0, 1] x-domain
1066
+ elsewhere in the wrap helper).
1067
+
1068
+ ``stack: True`` / ``None`` are accepted and collapsed to ``"zero"``
1069
+ (VL's implicit default for bar marks); ``False`` and ``"center"``
1070
+ raise — grouped bars don't stack and diverging stacks would need
1071
+ signed-midpoint anchors not yet implemented.
1072
+ """
1073
+ if stack_mode is True or stack_mode is None:
1074
+ stack_mode = "zero"
1075
+ if stack_mode not in ("zero", "normalize"):
1076
+ raise ChartDataError(
1077
+ f"endpoint_labels: stack mode {stack_mode!r} is not supported on "
1078
+ "horizontal bars — use 'zero' (the default) or 'normalize'. "
1079
+ "Grouped horizontal bars (stack: false) don't form stacks to "
1080
+ "label; diverging 'center' stacks would need signed midpoint "
1081
+ "anchors not yet implemented."
1082
+ )
1083
+
1084
+ distinct_rows = sorted(
1085
+ {str(r[x_field]) for r in data if x_field in r and r[x_field] is not None}
1086
+ )
1087
+ if not distinct_rows:
1088
+ return {}
1089
+ top_row = distinct_rows[0]
1090
+
1091
+ values_at_top: dict[str, float] = {}
1092
+ for row in data:
1093
+ if x_field not in row or row[x_field] is None:
1094
+ continue
1095
+ if str(row[x_field]) != top_row:
1096
+ continue
1097
+ s = row.get(series_field)
1098
+ v = row.get(y_field)
1099
+ if s is None or v is None:
1100
+ continue
1101
+ values_at_top[str(s)] = float(v)
1102
+ if not values_at_top:
1103
+ return {}
1104
+
1105
+ if any(v < 0 for v in values_at_top.values()):
1106
+ raise ChartDataError(
1107
+ "endpoint_labels: negative values in the top row of a horizontal "
1108
+ "stacked bar chart are not supported — VL's stacked domain spans "
1109
+ "both signs and the cumulative-midpoint computation would land "
1110
+ "labels off the segments. Set ``stack: false`` (grouped bars) or "
1111
+ "filter/transform the data upstream."
1112
+ )
1113
+
1114
+ # Series iteration order must match _apply_stacked_bar_z_order: the first
1115
+ # series in the iteration sits at the baseline (left, x=0 for horizontal).
1116
+ # VL stacks by the global sum (joinaggregate transform on encoding.order),
1117
+ # so we sort by global sum here too (not just the top-row values).
1118
+ if stack_order == "alphabetical":
1119
+ series_order = sorted(values_at_top)
1120
+ elif stack_order == "data":
1121
+ # VL builds its ordinal color domain in global first-encounter order across
1122
+ # all data rows — not the order at the reference row. Use the same traversal.
1123
+ seen: dict[str, None] = {}
1124
+ for row in data:
1125
+ s = row.get(series_field)
1126
+ if s is not None:
1127
+ seen[str(s)] = None
1128
+ series_order = [s for s in seen if s in values_at_top]
1129
+ else:
1130
+ # Default (None or "value"): globally-largest series at baseline.
1131
+ global_sums: dict[str, float] = {}
1132
+ for row in data:
1133
+ s = row.get(series_field)
1134
+ v = row.get(y_field)
1135
+ if s is None or v is None:
1136
+ continue
1137
+ key = str(s)
1138
+ global_sums[key] = global_sums.get(key, 0.0) + float(v)
1139
+ series_order = sorted(
1140
+ values_at_top, key=lambda s: (-global_sums.get(s, 0.0), s)
1141
+ )
1142
+
1143
+ total = sum(values_at_top[s] for s in series_order)
1144
+
1145
+ cum_lower = 0.0
1146
+ midpoints: dict[str, float] = {}
1147
+ for s in series_order:
1148
+ v = values_at_top[s]
1149
+ mid = cum_lower + v / 2.0
1150
+ if stack_mode == "normalize" and total > 0:
1151
+ mid = mid / total
1152
+ midpoints[s] = mid
1153
+ cum_lower += v
1154
+ return midpoints
1155
+
1156
+
1157
+ def _resolve_horizontal_label_dodge(
1158
+ midpoints: dict[str, float],
1159
+ label_widths_px: dict[str, float],
1160
+ chart_x_min: float,
1161
+ chart_x_max: float,
1162
+ chart_pane_width: float,
1163
+ gap_px: float,
1164
+ max_dodge: int,
1165
+ ) -> list[tuple[str, float, int]]:
1166
+ """Walk left-to-right; right-hand neighbor lifts on collision.
1167
+
1168
+ Returns ``[(series, x_mid, dodge_row)]`` ordered by ``x_mid`` ascending.
1169
+ The leftmost label always sits at ``dodge_row=0`` (the resolver's
1170
+ determinism contract — pinned in tests). Each subsequent label tries
1171
+ rows 0..``max_dodge``; if it collides at every row, it's placed at
1172
+ ``max_dodge`` anyway. We never raise: the alternative (dropping a
1173
+ label) makes the chart misread silently.
1174
+ """
1175
+ items = sorted(midpoints.items(), key=lambda kv: kv[1])
1176
+ if chart_x_max <= chart_x_min or chart_pane_width <= 0 or not items:
1177
+ return [(s, x, 0) for s, x in items]
1178
+
1179
+ domain_to_px = chart_pane_width / (chart_x_max - chart_x_min)
1180
+ placed: list[tuple[str, float, float, float, int]] = []
1181
+
1182
+ for series, x_mid in items:
1183
+ center_px = (x_mid - chart_x_min) * domain_to_px
1184
+ # Trust the caller: ``label_widths_px`` is built from the same
1185
+ # series keys that drive ``midpoints``. KeyError here would surface
1186
+ # a real cascade bug — defaulting to 0 would silently collapse the
1187
+ # bbox and make every collision check trivially pass, dropping
1188
+ # every right-hand label onto the base row.
1189
+ w_px = label_widths_px[series]
1190
+ x_lo = center_px - w_px / 2.0
1191
+ x_hi = center_px + w_px / 2.0
1192
+
1193
+ chosen = max_dodge
1194
+ for dodge in range(max_dodge + 1):
1195
+ collides = False
1196
+ for _, _, p_lo, p_hi, p_dodge in placed:
1197
+ if p_dodge != dodge:
1198
+ continue
1199
+ # Two intervals overlap iff each starts before the other's
1200
+ # gap-padded end. The negation: one ends before the other's
1201
+ # gap-padded start.
1202
+ if x_hi + gap_px <= p_lo or x_lo >= p_hi + gap_px:
1203
+ continue
1204
+ collides = True
1205
+ break
1206
+ if not collides:
1207
+ chosen = dodge
1208
+ break
1209
+
1210
+ placed.append((series, x_mid, x_lo, x_hi, chosen))
1211
+
1212
+ return [(s, x, d) for s, x, _, _, d in placed]
1213
+
1214
+
1215
+ def _build_horizontal_top_row_rail_pane(
1216
+ resolved_chart: ResolvedChart,
1217
+ data: list[dict[str, Any]],
1218
+ resolved_chart_style: MergedChartsStyle,
1219
+ main_spec: dict[str, Any],
1220
+ ) -> dict[str, Any]:
1221
+ """Build the top-row series-label rail for horizontal stacked bars.
1222
+
1223
+ Layout: a separate pane that stacks above the chart in a ``vconcat``
1224
+ with ``resolve.scale.x = shared``. Each series gets one text mark at
1225
+ the segment midpoint of the top categorical row (alphabetical-first
1226
+ y-domain value). When neighboring midpoints' label bboxes would
1227
+ overlap, the right-hand label lifts by ``__dodge_row`` line-heights
1228
+ (cap = ``_HORIZONTAL_LABEL_RAIL_MAX_DODGE``). The pane's ``height``
1229
+ is sized to the actual stacking depth before being returned.
1230
+ """
1231
+ # In horizontal bar specs the user-authored ``x`` is the categorical
1232
+ # axis (post-swap → emitted as encoding.y) and authored ``y`` is the
1233
+ # quantitative measure (post-swap → emitted as encoding.x). The
1234
+ # ResolvedChart still carries the AUTHORED field names; the rail
1235
+ # resolver works in authored space.
1236
+ x_field = resolved_chart.x or "x"
1237
+ y_field = (
1238
+ resolved_chart.y[0]
1239
+ if isinstance(resolved_chart.y, list)
1240
+ else (resolved_chart.y or "y")
1241
+ )
1242
+ color_ch = resolved_chart.resolved_channels["color"]
1243
+ series_field = color_ch.data_field
1244
+ assert series_field, (
1245
+ "horizontal endpoint-label rail invoked without a color/series field — "
1246
+ "endpoint_label_pane_will_fire should have gated this; cascade is broken"
1247
+ )
1248
+
1249
+ # The rail anchors at the *alphabetical-first* y-domain value (VL's
1250
+ # default sort for nominal scales). When the chart authors a non-default
1251
+ # sort, the rendered top row is whatever that sort puts first — likely
1252
+ # not alphabetical-first. Honoring an arbitrary sort here would require
1253
+ # mirroring VL's full sort-resolution machinery (sort-by-measure,
1254
+ # sort-array, sort-by-encoding); raising is the validate-and-error-fast
1255
+ # response so labels never silently anchor on the wrong row.
1256
+ if resolved_chart.sort is not None:
1257
+ raise ChartDataError(
1258
+ "endpoint_labels: a chart.sort on a horizontal stacked bar with "
1259
+ "endpoint_labels.visible is not supported — the rail anchors at "
1260
+ "the alphabetical-first row, but a custom sort would put a "
1261
+ "different row at the top of the chart and the labels would "
1262
+ "land on the wrong row's segments. Drop the sort, or disable "
1263
+ "endpoint_labels on this chart."
1264
+ )
1265
+
1266
+ midpoints = _resolve_horizontal_top_row_label_anchors(
1267
+ data,
1268
+ x_field,
1269
+ y_field,
1270
+ series_field,
1271
+ stack_mode=resolved_chart.stack,
1272
+ stack_order=resolved_chart.resolved_style.bar.stack_order,
1273
+ )
1274
+
1275
+ color_domain_order, dark_stops = _dark_companion_stops(
1276
+ data, series_field, list(resolved_chart_style.palette)
1277
+ )
1278
+
1279
+ mark_font_props, label_font_family, label_font_size = _label_mark_font(
1280
+ resolved_chart_style
1281
+ )
1282
+
1283
+ # Measure label widths once so the dodge resolver can decide whether two
1284
+ # adjacent labels' bboxes intersect.
1285
+ measurer = get_font_measurer(label_font_family)
1286
+ widths_px = {s: measurer.measure(s, label_font_size) for s in midpoints}
1287
+
1288
+ chart_pane_width_raw = main_spec.get("width")
1289
+ assert (
1290
+ isinstance(chart_pane_width_raw, (int, float)) and chart_pane_width_raw > 0
1291
+ ), (
1292
+ "horizontal endpoint-label rail invoked without a positive chart width; "
1293
+ f"got {chart_pane_width_raw!r}. The standard render path always supplies "
1294
+ "width — fix the caller rather than defaulting here."
1295
+ )
1296
+ chart_pane_width = float(chart_pane_width_raw)
1297
+
1298
+ if resolved_chart.stack == "normalize":
1299
+ x_min, x_max = 0.0, 1.0
1300
+ else:
1301
+ # Top-row's stacked total is the chart's x-domain max (the longest
1302
+ # bar in the chart sets the scale; for stack-zero VL extends the
1303
+ # quantitative axis to max-of-totals across rows). We measure
1304
+ # collision in pixel space relative to that scale.
1305
+ x_min = 0.0
1306
+ totals: dict[Any, float] = {}
1307
+ for row in data:
1308
+ xv = row.get(x_field)
1309
+ yv = row.get(y_field)
1310
+ if xv is None or yv is None:
1311
+ continue
1312
+ totals[xv] = totals.get(xv, 0.0) + float(yv)
1313
+ x_max = max(totals.values()) if totals else 1.0
1314
+
1315
+ positions = _resolve_horizontal_label_dodge(
1316
+ midpoints,
1317
+ widths_px,
1318
+ chart_x_min=x_min,
1319
+ chart_x_max=x_max,
1320
+ chart_pane_width=chart_pane_width,
1321
+ gap_px=_HORIZONTAL_LABEL_RAIL_X_GAP_PX,
1322
+ max_dodge=_HORIZONTAL_LABEL_RAIL_MAX_DODGE,
1323
+ )
1324
+
1325
+ label_pane_data = [
1326
+ {
1327
+ series_field: s,
1328
+ _LABEL_X_ALIAS: x,
1329
+ _LABEL_DODGE_ALIAS: d,
1330
+ }
1331
+ for s, x, d in positions
1332
+ ]
1333
+ max_dodge_used = max((d for _, _, d in positions), default=0)
1334
+
1335
+ line_height_px = float(label_font_size) * _LABEL_LINE_HEIGHT_MULTIPLIER
1336
+ rail_height = (max_dodge_used + 1) * line_height_px + line_height_px / 2.0
1337
+
1338
+ pane_spec: dict[str, Any] = {
1339
+ "width": chart_pane_width,
1340
+ "height": rail_height,
1341
+ "view": {"stroke": None},
1342
+ "data": {"values": label_pane_data},
1343
+ "mark": {
1344
+ "type": "text",
1345
+ "align": "center",
1346
+ "baseline": "bottom",
1347
+ **mark_font_props,
1348
+ },
1349
+ "encoding": {
1350
+ "x": {
1351
+ "field": _LABEL_X_ALIAS,
1352
+ "type": "quantitative",
1353
+ "axis": None,
1354
+ },
1355
+ "y": {
1356
+ "field": _LABEL_DODGE_ALIAS,
1357
+ "type": "quantitative",
1358
+ "axis": None,
1359
+ # Pin the y-domain so dodge_row=0 lands at the bottom of the
1360
+ # rail pane (closest to the chart), max_dodge_used at the
1361
+ # top — independent of the actual values present in the
1362
+ # data so a chart that doesn't trigger dodge still places
1363
+ # all labels just above the bar (not centered in the pane).
1364
+ "scale": {"domain": [0, max(max_dodge_used, 1)]},
1365
+ },
1366
+ "color": {
1367
+ "field": series_field,
1368
+ "type": "nominal",
1369
+ "scale": {
1370
+ "domain": color_domain_order,
1371
+ "range": dark_stops,
1372
+ },
1373
+ "legend": None,
1374
+ },
1375
+ "text": {"field": series_field},
1376
+ },
1377
+ }
1378
+ return pane_spec
1379
+
1380
+
1381
+ def _build_endpoint_label_pane(
1382
+ resolved_chart: ResolvedChart,
1383
+ data: list[dict[str, Any]],
1384
+ resolved_chart_style: MergedChartsStyle,
1385
+ main_spec: dict[str, Any],
1386
+ ) -> dict[str, Any]:
1387
+ """Build the right-side label pane for multi-series line/area endpoint labels.
1388
+
1389
+ Each label anchors to its series' last data point via a shared y scale
1390
+ with the main pane; when several series cluster at similar y values
1391
+ a bidirectional greedy nudging pass (`_resolve_endpoint_label_positions`)
1392
+ spreads colliding labels apart by a fixed pixel gap derived from
1393
+ `series_label.font.size`. Label text is the series name only — the coloured
1394
+ stroke/fill carries series identity.
1395
+ """
1396
+ x_field = resolved_chart.x or "x"
1397
+ series_field = (
1398
+ resolved_chart.resolved_channels["color"].data_field or "series"
1399
+ if resolved_chart.resolved_channels.get("color")
1400
+ else "series"
1401
+ )
1402
+ y_field = (
1403
+ resolved_chart.y[0]
1404
+ if isinstance(resolved_chart.y, list)
1405
+ else (resolved_chart.y or "y")
1406
+ )
1407
+
1408
+ # The sizing pass sets spec["height"] to width/aspect_ratio (the VL plot
1409
+ # area in pixels). Use it so min_data_gap scales correctly when the chart
1410
+ # is shorter than DEFAULT_CHART_HEIGHT. Fall back to None
1411
+ # so _label_pane_min_data_gap uses view.continuous_height instead.
1412
+ raw_spec_height = main_spec.get("height")
1413
+ spec_height: float | None = (
1414
+ float(raw_spec_height)
1415
+ if isinstance(raw_spec_height, (int, float)) and raw_spec_height > 0
1416
+ else None
1417
+ )
1418
+
1419
+ if resolved_chart.chart_type == "bar":
1420
+ # Bar's effective y-domain is the stacked-total range (or [0,1] for
1421
+ # normalize) — vega-lite computes it at render time and the spec
1422
+ # carries no explicit ``scale.domain`` to extract.
1423
+ stack_mode: str | bool | None = resolved_chart.stack
1424
+ # ``stack: True`` is VL's implicit default ("zero" for bars); collapse
1425
+ # so downstream resolver/domain helpers see one canonical mode.
1426
+ # Grouped-by-default bars anchor at bar top, not at stacked midpoints.
1427
+ if stack_mode is True:
1428
+ stack_mode = "zero"
1429
+ elif is_grouped_bar(resolved_chart):
1430
+ stack_mode = False
1431
+ y_domain_min, y_domain_max = _bar_stack_y_domain(
1432
+ data, x_field, y_field, stack_mode
1433
+ )
1434
+ min_data_gap = _label_pane_min_data_gap(
1435
+ resolved_chart_style, y_domain_min, y_domain_max, spec_height=spec_height
1436
+ )
1437
+ positions = _resolve_bar_endpoint_label_positions(
1438
+ data,
1439
+ x_field,
1440
+ y_field,
1441
+ series_field,
1442
+ stack_mode=stack_mode,
1443
+ min_data_gap=min_data_gap,
1444
+ y_domain_min=y_domain_min,
1445
+ y_domain_max=y_domain_max,
1446
+ stack_order=resolved_chart.resolved_style.bar.stack_order,
1447
+ )
1448
+ else:
1449
+ y_domain_min, y_domain_max = _extract_y_domain(main_spec, data, y_field)
1450
+ min_data_gap = _label_pane_min_data_gap(
1451
+ resolved_chart_style, y_domain_min, y_domain_max, spec_height=spec_height
1452
+ )
1453
+ positions = _resolve_endpoint_label_positions(
1454
+ data,
1455
+ x_field,
1456
+ y_field,
1457
+ series_field,
1458
+ min_data_gap=min_data_gap,
1459
+ y_domain_min=y_domain_min,
1460
+ y_domain_max=y_domain_max,
1461
+ )
1462
+
1463
+ color_domain_order, dark_stops = _dark_companion_stops(
1464
+ data, series_field, list(resolved_chart_style.palette)
1465
+ )
1466
+
1467
+ label_pane_data = [{series_field: s, _LABEL_Y_ALIAS: y} for s, y in positions]
1468
+
1469
+ mark_font_props, label_font_family, label_font_size = _label_mark_font(
1470
+ resolved_chart_style
1471
+ )
1472
+ measurer = get_font_measurer(label_font_family)
1473
+ _GAP_PX = 4.0
1474
+ max_label_px = max(
1475
+ (measurer.measure(str(s), label_font_size) for s in color_domain_order),
1476
+ default=0.0,
1477
+ )
1478
+ label_width = max_label_px + _GAP_PX
1479
+ chart_width_raw = main_spec.get("width")
1480
+ if isinstance(chart_width_raw, (int, float)) and chart_width_raw > 0:
1481
+ # Cap the pane at 1/3 of the chart width. Labels wider than the cap
1482
+ # are truncated by Vega via the mark's limit property below. The chart
1483
+ # grows rightward to accommodate the pane, so no error is raised.
1484
+ label_width = min(label_width, chart_width_raw / 3.0)
1485
+
1486
+ label_mark = {
1487
+ "type": "text",
1488
+ "align": "left",
1489
+ "baseline": "middle",
1490
+ **mark_font_props,
1491
+ # Truncate labels that exceed the pane width with an ellipsis.
1492
+ # Kicks in when label_width was capped at chart_width/3 above.
1493
+ "limit": label_width,
1494
+ }
1495
+
1496
+ pane: dict[str, Any] = {
1497
+ "width": label_width,
1498
+ "view": {"stroke": None},
1499
+ "data": {"values": label_pane_data},
1500
+ "mark": label_mark,
1501
+ "encoding": {
1502
+ "x": {"value": 0},
1503
+ "y": {
1504
+ "field": _LABEL_Y_ALIAS,
1505
+ "type": "quantitative",
1506
+ "axis": None,
1507
+ },
1508
+ "color": {
1509
+ "field": series_field,
1510
+ "type": "nominal",
1511
+ "scale": {
1512
+ "domain": color_domain_order,
1513
+ "range": dark_stops,
1514
+ },
1515
+ "legend": None,
1516
+ },
1517
+ "text": {"field": series_field},
1518
+ },
1519
+ }
1520
+ # Pin the label pane to the same height as pane[0]. Without this, VL
1521
+ # defaults the label pane to view.continuousHeight (300px), which is taller
1522
+ # than pane[0] when the sizing pass renders at a smaller spec_height. The
1523
+ # taller pane then governs the hconcat total height, making the chart with
1524
+ # endpoint labels taller than the same chart without labels.
1525
+ if spec_height is not None and spec_height > 0:
1526
+ pane["height"] = spec_height
1527
+ return pane
1528
+
1529
+
1530
+ def endpoint_label_pane_will_fire(
1531
+ resolved_chart: ResolvedChart,
1532
+ resolved_chart_style: MergedChartsStyle,
1533
+ ) -> bool:
1534
+ """Return True iff the endpoint-label pane will actually render.
1535
+
1536
+ Used by both ``_maybe_wrap_endpoint_label_pane`` (decides whether to
1537
+ emit the hconcat label pane) and ``_resolve_orient_auto`` in
1538
+ ``profile.py`` (decides whether to flip ``axis_y`` to the left
1539
+ because the right-edge label pane would collide with a right-side
1540
+ y-axis). Centralizing the predicate keeps the two helpers from
1541
+ diverging — divergence meant single-series and horizontal-bar
1542
+ charts authoring ``endpoint_labels.visible: true`` got their
1543
+ y-axis silently flipped without a pane to justify the flip.
1544
+
1545
+ Firing conditions, in order:
1546
+
1547
+ 1. Chart type is ``line``, ``area``, or ``bar`` (the only families
1548
+ with a typed ``endpoint_labels`` field).
1549
+ 2. The family's compiled style has ``endpoint_labels.visible``.
1550
+ 3. For horizontal bar: stack mode must be truthy (``zero`` /
1551
+ ``normalize`` / VL's implicit default). The rail labels color
1552
+ segments inside a single row; grouped horizontal bars (``stack:
1553
+ false``) sit side-by-side and
1554
+ don't form stacks to label.
1555
+ 4. The color channel is in ``"series"`` mode with a data field
1556
+ (multi-series only — single-series charts don't need direct
1557
+ labels at series endpoints because there's only one series).
1558
+ """
1559
+ if resolved_chart.chart_type not in ("line", "area", "bar"):
1560
+ return False
1561
+ # Direct attribute access: every family in the allowlist owns a typed
1562
+ # ``endpoint_labels: EndpointLabelsConfig`` field on its compiled
1563
+ # style. ``getattr(..., None)`` here would mask the same cascade-dropped-
1564
+ # my-override class of bug the font_size assert below catches.
1565
+ chart_family_style = getattr(resolved_chart_style, resolved_chart.chart_type)
1566
+ if not chart_family_style.endpoint_labels.visible:
1567
+ return False
1568
+ if (
1569
+ resolved_chart.chart_type == "bar"
1570
+ and resolved_chart.orientation == "horizontal"
1571
+ ):
1572
+ # Top-row series rail only applies to stacked horizontals.
1573
+ # Grouped bars — explicit ``stack: false`` or grouped-by-default — don't
1574
+ # form stacks to label.
1575
+ if resolved_chart.stack is False or is_grouped_bar(resolved_chart):
1576
+ return False
1577
+ color_ch = resolved_chart.resolved_channels.get("color")
1578
+ return not (
1579
+ color_ch is None or color_ch.mode != "series" or not color_ch.data_field
1580
+ )
1581
+
1582
+
1583
+ def _maybe_wrap_endpoint_label_pane(
1584
+ spec: dict[str, Any],
1585
+ resolved_chart: ResolvedChart,
1586
+ data: list[dict[str, Any]],
1587
+ resolved_chart_style: MergedChartsStyle,
1588
+ ) -> dict[str, Any]:
1589
+ """Wrap the main spec in hconcat with a label pane if endpoint labels are enabled.
1590
+
1591
+ Firing condition delegated to ``endpoint_label_pane_will_fire``;
1592
+ see that function for the full predicate.
1593
+ """
1594
+ if not endpoint_label_pane_will_fire(resolved_chart, resolved_chart_style):
1595
+ return spec
1596
+
1597
+ chart_family_style = getattr(resolved_chart_style, resolved_chart.chart_type)
1598
+ endpoint_labels_cfg = chart_family_style.endpoint_labels
1599
+
1600
+ if (
1601
+ resolved_chart.chart_type == "bar"
1602
+ and resolved_chart.orientation == "horizontal"
1603
+ ):
1604
+ return _wrap_horizontal_top_row_rail(
1605
+ spec, resolved_chart, data, resolved_chart_style, endpoint_labels_cfg
1606
+ )
1607
+
1608
+ label_pane = _build_endpoint_label_pane(
1609
+ resolved_chart, data, resolved_chart_style, spec
1610
+ )
1611
+
1612
+ # Auto-disable the categorical legend on pane[0]. A right-edge label pane
1613
+ # and a side legend encode the same series→colour mapping; rendering both
1614
+ # is double-encoding (Cleveland on direct labels), and the legend's right-
1615
+ # side overhang blows past the wrap helper's pane[0] width budget, so the
1616
+ # label pane clips out of the canvas.
1617
+ color_enc = spec.get("encoding", {}).get("color")
1618
+ if isinstance(color_enc, dict):
1619
+ color_enc["legend"] = None
1620
+
1621
+ # Pin pane[0]'s y-scale domain to [0, 1] when stack=normalize. VL's auto-
1622
+ # scale on the raw value field would yield [0, max(value)], and the
1623
+ # wrapper's resolve.scale.y=shared then propagates that raw-domain scale
1624
+ # to the label pane — squashing every label near zero on 100% stacks.
1625
+ # The label-position resolver already works in [0, 1] for normalize
1626
+ # (_bar_stack_y_domain), so this pin makes pane[0]'s rendered scale agree
1627
+ # with the label-pane domain. Resolved in endpoint-labels.md.
1628
+ if resolved_chart.chart_type == "bar" and resolved_chart.stack == "normalize":
1629
+ y_enc = spec.get("encoding", {}).get("y")
1630
+ if isinstance(y_enc, dict):
1631
+ scale = y_enc.setdefault("scale", {})
1632
+ if isinstance(scale, dict):
1633
+ scale["domain"] = [0, 1]
1634
+
1635
+ # vl-convert ignores autosize:fit on hconcat children
1636
+ # (https://vega.github.io/vega-lite/docs/size.html#limitations).
1637
+ # Stamp the intended total SVG width on the wrapper; render_vega_spec
1638
+ # does a two-pass render: first to measure the actual SVG width, then
1639
+ # shrinks pane[0].width by the overshoot before the real render.
1640
+ spacing = endpoint_labels_cfg.label_offset
1641
+ pane0_width = spec.get("width")
1642
+ pane0_height = spec.get("height")
1643
+
1644
+ # Lift spec-level properties from the main spec to the hconcat root.
1645
+ # vl-convert treats $schema/config/background/data as top-level-only:
1646
+ # when they sit inside a concat child, theme config is silently dropped
1647
+ # (default vega palette + default tick chrome) and pane[1] cannot resolve
1648
+ # the dataset (renders as an empty <g>). `title`, `padding`, and `autosize`
1649
+ # stay on pane[0] so vega-lite anchors the title over the chart body's
1650
+ # visual bounds (not the full hconcat including the label pane — hoisting
1651
+ # title to the wrapper shifts it ~20px rightward for wide label panes).
1652
+ hoist_keys = (
1653
+ "$schema",
1654
+ "config",
1655
+ "background",
1656
+ "data",
1657
+ )
1658
+ hoisted = {k: spec.pop(k) for k in hoist_keys if k in spec}
1659
+
1660
+ wrapper: dict[str, Any] = {
1661
+ **hoisted,
1662
+ "spacing": spacing,
1663
+ # y must be SHARED so labels align with each line's actual endpoint
1664
+ # pixel y. hconcat's default is `independent` for position channels,
1665
+ # which would let pane[1] derive its own y scale from the filtered
1666
+ # last-x rows and float labels off the line endpoints. color stays
1667
+ # independent so the label pane uses its own dark companion stops.
1668
+ "resolve": {"scale": {"color": "independent", "y": "shared"}},
1669
+ "hconcat": [spec, label_pane],
1670
+ **(
1671
+ {"$df_target_width": float(pane0_width)}
1672
+ if isinstance(pane0_width, (int, float)) and pane0_width > 0
1673
+ else {}
1674
+ ),
1675
+ **(
1676
+ {"$df_target_height": float(pane0_height)}
1677
+ if isinstance(pane0_height, (int, float)) and pane0_height > 0
1678
+ else {}
1679
+ ),
1680
+ }
1681
+ return wrapper
1682
+
1683
+
1684
+ def _wrap_horizontal_top_row_rail(
1685
+ spec: dict[str, Any],
1686
+ resolved_chart: ResolvedChart,
1687
+ data: list[dict[str, Any]],
1688
+ resolved_chart_style: MergedChartsStyle,
1689
+ endpoint_labels_cfg: EndpointLabelsConfig,
1690
+ ) -> dict[str, Any]:
1691
+ """Compose a vconcat with the series-label rail above the horizontal chart.
1692
+
1693
+ Layout asymmetry is by design: vertical stacks read top-to-bottom along
1694
+ the measure axis, so per-segment labels sit on the SIDE in an hconcat;
1695
+ horizontal stacks read left-to-right inside one row, so a single rail
1696
+ of one-label-per-series sits ABOVE the top categorical row. Both
1697
+ follow from the same reading-direction principle (label rail runs
1698
+ perpendicular to the measure axis).
1699
+ """
1700
+ rail_pane = _build_horizontal_top_row_rail_pane(
1701
+ resolved_chart, data, resolved_chart_style, spec
1702
+ )
1703
+
1704
+ # Direct labels and a side legend double-encode the same series→colour
1705
+ # mapping; suppress the cascade-default legend when wrapping. Mirrors
1706
+ # the hconcat path's auto-disable.
1707
+ color_enc = spec.get("encoding", {}).get("color")
1708
+ if isinstance(color_enc, dict):
1709
+ color_enc["legend"] = None
1710
+
1711
+ # Pin the chart pane's x-scale (post-swap measure axis) to [0, 1] when
1712
+ # stack=normalize. Without the pin VL's auto-scale on the raw value
1713
+ # field would yield [0, max(value)] and ``resolve.scale.x = shared``
1714
+ # propagates that raw-domain scale to the rail pane — squashing every
1715
+ # label near the left edge. The rail resolver works on [0, 1] for
1716
+ # normalize, so this keeps the shared scale agreeing with it.
1717
+ if resolved_chart.stack == "normalize":
1718
+ x_enc = spec.get("encoding", {}).get("x")
1719
+ if isinstance(x_enc, dict):
1720
+ scale = x_enc.setdefault("scale", {})
1721
+ if isinstance(scale, dict):
1722
+ scale["domain"] = [0, 1]
1723
+
1724
+ spacing = endpoint_labels_cfg.label_offset
1725
+
1726
+ # Reserve room for the rail pane inside the chart's allocated cell
1727
+ # height. vl-convert ignores ``autosize: fit`` on vconcat the same way
1728
+ # it ignores it on hconcat, so the outer SVG ends up as
1729
+ # ``rail_pane.height + spacing + chart_pane.height + padding`` and
1730
+ # overflows the configured cell height. Mirror the hconcat path's
1731
+ # width-reduction by subtracting the rail's footprint from the chart
1732
+ # pane's height.
1733
+ rail_pane_height = float(rail_pane.get("height", 0) or 0)
1734
+ pane_height = spec.get("height")
1735
+ if isinstance(pane_height, (int, float)) and pane_height > 0:
1736
+ reduced = float(pane_height) - rail_pane_height - spacing
1737
+ if reduced > 0:
1738
+ spec["height"] = reduced
1739
+
1740
+ # Hoist top-level keys (same set as hconcat) so vl-convert resolves
1741
+ # theme config, $schema, the dataset, and chrome at the wrapper root
1742
+ # rather than dropping them when they sit inside a concat child.
1743
+ # Title hoists cleanly here — both panes are equal-width via
1744
+ # ``resolve.scale.x = shared``, so the title centers correctly over
1745
+ # both. The hconcat path keeps title on pane[0] for the opposite
1746
+ # reason (uneven pane widths shift a hoisted title rightward).
1747
+ hoist_keys = (
1748
+ "$schema",
1749
+ "config",
1750
+ "background",
1751
+ "title",
1752
+ "data",
1753
+ )
1754
+ hoisted = {k: spec.pop(k) for k in hoist_keys if k in spec}
1755
+
1756
+ pane_target_width = spec.get("width")
1757
+ wrapper: dict[str, Any] = {
1758
+ **hoisted,
1759
+ "spacing": spacing,
1760
+ # x must be SHARED so each label centers on the actual segment's
1761
+ # rendered pixel x — vconcat's default is `independent`, which
1762
+ # would let the rail pane derive its own x-scale from the
1763
+ # filtered top-row data and float labels off the segments.
1764
+ "resolve": {"scale": {"color": "independent", "x": "shared"}},
1765
+ "vconcat": [rail_pane, spec],
1766
+ **(
1767
+ {"$df_target_width": float(pane_target_width)}
1768
+ if isinstance(pane_target_width, (int, float)) and pane_target_width > 0
1769
+ else {}
1770
+ ),
1771
+ }
1772
+ return wrapper
1773
+
1774
+
1775
+ def _inject_zero_baseline_rule(
1776
+ spec: dict[str, Any],
1777
+ resolved_chart: ResolvedChart,
1778
+ data: list[dict[str, Any]] | None,
1779
+ ) -> dict[str, Any]:
1780
+ """Insert zero (and, for normalize-stacked, top) baseline rule layers.
1781
+
1782
+ Vega renders axis guides (grid lines) below marks, so area/bar fills cover
1783
+ the bold zero baseline. Insertion position is chart-type-specific:
1784
+
1785
+ - bar/layered: append last — rule renders above all fills.
1786
+ - line: insert first — rule renders below strokes so lines cross y=0
1787
+ without being interrupted by the baseline.
1788
+ - area: insert after the last ``type: "area"`` layer — rule renders above
1789
+ the fg area fill but below the fg stroke (line mark). Layer order:
1790
+ halo_fill, halo_stroke, fg_fill, zero_rule, fg_stroke, hover_overlay.
1791
+
1792
+ For ``stack: normalize`` charts, also insert a sibling rule at y=1 in the
1793
+ same position as the zero rule.
1794
+
1795
+ Skipped when both rule helpers return None (wrong chart type, 0 not in
1796
+ y-domain, horizontal bar, grid hidden, or non-normalize stack for the top
1797
+ rule). Single-mark bar specs are converted to layered with the bar mark in
1798
+ ``layer[0]`` and rules appended after.
1799
+ """
1800
+ from dataface.core.render.chart.profile import (
1801
+ top_rule_vl_layer,
1802
+ zero_rule_vl_layer,
1803
+ )
1804
+
1805
+ if data is None:
1806
+ data = []
1807
+ zero_rule = zero_rule_vl_layer(resolved_chart, data)
1808
+ top_rule = top_rule_vl_layer(resolved_chart, data)
1809
+
1810
+ rules = [r for r in (zero_rule, top_rule) if r is not None]
1811
+ if not rules:
1812
+ return spec
1813
+ if "layer" in spec:
1814
+ chart_type = resolved_chart.chart_type
1815
+ if chart_type == "line":
1816
+ spec["layer"] = [*rules, *spec["layer"]]
1817
+ elif chart_type == "area":
1818
+ # Insert after the last area-fill layer so the zero rule appears
1819
+ # above fills but below the separate stroke (line) layers.
1820
+ # _map_area emits fill layers (type: "area") before stroke layers
1821
+ # (type: "line"), so scanning for the last area-type mark gives
1822
+ # the correct insertion point.
1823
+ layers = spec["layer"]
1824
+ last_area_idx = -1
1825
+ for i, layer in enumerate(layers):
1826
+ mark = layer.get("mark", {})
1827
+ mark_type = mark.get("type") if isinstance(mark, dict) else mark
1828
+ if mark_type == "area":
1829
+ last_area_idx = i
1830
+ if last_area_idx >= 0:
1831
+ spec["layer"] = [
1832
+ *layers[: last_area_idx + 1],
1833
+ *rules,
1834
+ *layers[last_area_idx + 1 :],
1835
+ ]
1836
+ elif len(layers) >= 2:
1837
+ spec["layer"] = [*layers[:-1], *rules, layers[-1]]
1838
+ else:
1839
+ spec["layer"].extend(rules)
1840
+ else:
1841
+ spec["layer"].extend(rules)
1842
+ return spec
1843
+ if "mark" not in spec:
1844
+ return spec # not a chart spec we can wrap
1845
+ spec["layer"] = [{"mark": spec.pop("mark")}, *rules]
1846
+ return spec
1847
+
1848
+
1849
+ def _finalize(
1850
+ spec: dict[str, Any],
1851
+ resolved_chart: ResolvedChart,
1852
+ theme: str | None,
1853
+ resolved_chart_style: MergedChartsStyle,
1854
+ width: float | None = None,
1855
+ padding: dict[str, Any] | None = None,
1856
+ data: list[dict[str, Any]] | None = None,
1857
+ face_background_overlay: str | None = None,
1858
+ face_level: int = 1,
1859
+ effective_vega_config: dict[str, Any] | None = None,
1860
+ ) -> dict[str, Any]:
1861
+ """Apply passthrough overrides, click interactivity, and finalize spec."""
1862
+ spec = _apply_projection_override(spec, resolved_chart)
1863
+ spec = _apply_click_interactivity(spec, resolved_chart)
1864
+ # Apply card padding before finalize so apply_title_overflow_to_spec computes
1865
+ # the title limit against the actual Vega padding, not the default spec padding.
1866
+ if padding is not None:
1867
+ spec["padding"] = padding
1868
+ finalized = _finalize_standard_spec(
1869
+ spec,
1870
+ theme,
1871
+ resolved_chart_style,
1872
+ width=width,
1873
+ face_background_overlay=face_background_overlay,
1874
+ face_level=face_level,
1875
+ effective_vega_config=effective_vega_config,
1876
+ )
1877
+ finalized = _inject_zero_baseline_rule(finalized, resolved_chart, data)
1878
+ if data is not None:
1879
+ finalized = _maybe_wrap_endpoint_label_pane(
1880
+ finalized, resolved_chart, data, resolved_chart_style
1881
+ )
1882
+ # Inject after endpoint-label wrapping so legend suppression (color_enc["legend"]=None)
1883
+ # is already applied and the check accurately reflects what's rendered.
1884
+ finalized = _inject_legend_toggle_param(
1885
+ finalized, resolved_chart, resolved_chart_style
1886
+ )
1887
+ return finalized
1888
+
1889
+
1890
+ def _resolve_effective_background(
1891
+ resolved_chart_style: MergedChartsStyle,
1892
+ theme: str | None,
1893
+ face_background_overlay: str | None = None,
1894
+ ) -> str | None:
1895
+ """Return the chart's effective background colour.
1896
+
1897
+ Precedence:
1898
+ 1. Chart-local override (``resolved_chart_style.background``)
1899
+ 2. ``face_background_overlay`` — face-level background from ``MergedStyle.background``
1900
+ (cascade: authored value or theme default); None when no MergedStyle is available.
1901
+ 3. Theme background looked up by name via ``compile_effective_vega_config``
1902
+ — fallback path for callers that don't carry a MergedStyle.
1903
+ """
1904
+ if resolved_chart_style.background is not None:
1905
+ return resolved_chart_style.background
1906
+ if face_background_overlay is not None:
1907
+ return face_background_overlay
1908
+ compiled = compile_effective_vega_config(theme=theme)
1909
+ bg = compiled.get("background")
1910
+ return bg if isinstance(bg, str) else None
1911
+
1912
+
1913
+ def _aggregate_by_x(
1914
+ data: list[dict[str, Any]],
1915
+ source: str,
1916
+ x_field: str,
1917
+ op: str,
1918
+ ) -> list[float]:
1919
+ """Group data by x_field, apply aggregate op, return resulting values.
1920
+
1921
+ Mirrors the VL aggregate transform used in _row_text_layer so that width
1922
+ measurements reflect the actual rendered strings. op must be one of:
1923
+ sum, avg, min, max, median, count, count_distinct.
1924
+
1925
+ count/count_distinct do not coerce source values to float — they count
1926
+ rows / distinct raw values so non-numeric sources are first-class.
1927
+ For sum/avg/min/max/median, float() is intentionally not suppressed:
1928
+ a non-numeric source with a numeric aggregate is a query authoring error
1929
+ and should surface as ValueError, not silently produce wrong dx.
1930
+
1931
+ Both ops share the _AGG_OP_TO_VL enum in data_table_attachment.py;
1932
+ update that mapping when adding a new op here.
1933
+ """
1934
+ # count/count_distinct: key on raw values directly (no float coercion)
1935
+ if op in ("count", "count_distinct"):
1936
+ raw_groups: dict[Any, list[Any]] = defaultdict(list)
1937
+ for row in data:
1938
+ x_val = row.get(x_field)
1939
+ raw = row.get(source)
1940
+ if x_val is None:
1941
+ continue
1942
+ raw_groups[x_val].append(raw)
1943
+ if op == "count":
1944
+ return [float(len(vals)) for vals in raw_groups.values() if vals]
1945
+ return [
1946
+ float(len({v for v in vals if v is not None}))
1947
+ for vals in raw_groups.values()
1948
+ if vals
1949
+ ]
1950
+
1951
+ # numeric ops: float() is intentionally not suppressed — non-numeric source
1952
+ # values here mean a query authoring error, not an expected no-op.
1953
+ groups: dict[Any, list[float]] = defaultdict(list)
1954
+ for row in data:
1955
+ x_val = row.get(x_field)
1956
+ raw = row.get(source)
1957
+ if x_val is None or raw is None:
1958
+ continue
1959
+ groups[x_val].append(float(raw))
1960
+
1961
+ results: list[float] = []
1962
+ for vals in groups.values():
1963
+ if not vals:
1964
+ continue
1965
+ if op == "sum":
1966
+ results.append(sum(vals))
1967
+ elif op == "avg":
1968
+ results.append(sum(vals) / len(vals))
1969
+ elif op == "min":
1970
+ results.append(min(vals))
1971
+ elif op == "max":
1972
+ results.append(max(vals))
1973
+ elif op == "median":
1974
+ results.append(statistics.median(vals))
1975
+ else:
1976
+ raise ValueError(f"unknown aggregate op {op!r}")
1977
+ return results
1978
+
1979
+
1980
+ def _aggregate_by_x_and_color(
1981
+ data: list[dict[str, Any]],
1982
+ source: str,
1983
+ x_field: str,
1984
+ color_field: str,
1985
+ ) -> list[float]:
1986
+ """Sum ``source`` grouped by (x, color), mirroring the per_series VL transform.
1987
+
1988
+ The per_series text layer in :mod:`data_table_attachment` emits a sum
1989
+ aggregate keyed on ``[x_field, color_field]`` and a per-series filter,
1990
+ so each rendered cell shows that one series' value at that x. This
1991
+ helper produces the matching width-measurement input — sum-per-(x,
1992
+ series) — so dx reflects the *actual* rendered cell widths rather
1993
+ than the across-series sum.
1994
+ """
1995
+ groups: dict[tuple[Any, Any], list[float]] = defaultdict(list)
1996
+ for row in data:
1997
+ x_val = row.get(x_field)
1998
+ c_val = row.get(color_field)
1999
+ raw = row.get(source)
2000
+ if x_val is None or c_val is None or raw is None:
2001
+ continue
2002
+ groups[(x_val, c_val)].append(float(raw))
2003
+ return [sum(vals) for vals in groups.values() if vals]
2004
+
2005
+
2006
+ def _compute_data_table_entry_dx(
2007
+ data_table: ChartDataTable,
2008
+ data: list[dict[str, Any]],
2009
+ dt_style: DataTableStyle,
2010
+ has_time_unit: bool,
2011
+ x_field: str | None = None,
2012
+ x_type: str | None = None,
2013
+ color_field: str | None = None,
2014
+ formats: dict[str, str] | None = None,
2015
+ ) -> list[float] | None:
2016
+ """Compute per-entry dx offsets for band-centred number lanes.
2017
+
2018
+ Returns None when no offset is needed. dx is only meaningful on band-scale
2019
+ axes (ordinal/nominal) where bandPosition:0.5 pins the text anchor to the
2020
+ band centre. For continuous axes (temporal without timeUnit, quantitative)
2021
+ there are no bands — a non-zero dx would shift the text away from the data
2022
+ point rather than centering it.
2023
+
2024
+ When dx is applicable, returns a list of floats, one per entry:
2025
+ dx = max_formatted_width / 2 so that right-aligned text's right edge sits
2026
+ at band_center + max_w/2, centering the column over the bar.
2027
+
2028
+ For ChartDataTableAggregate entries, data is pre-aggregated per x_field
2029
+ before measuring widths so the dx reflects the actual rendered values
2030
+ (aggregates are typically wider than any individual raw row).
2031
+
2032
+ Mirrors the centre-on-midpoint invariant of _compute_lane_positions
2033
+ (table.py): the number lane is centred; decimals still align within it.
2034
+ """
2035
+ # Temporal+timeUnit strips already use bandPosition:1.0 (right-edge anchor).
2036
+ # Adding dx there would double-shift the text — skip centering.
2037
+ if has_time_unit:
2038
+ return None
2039
+ # Continuous axes (quantitative, temporal without timeUnit) have no bands.
2040
+ # dx on these would shift text off the data point rather than centering it.
2041
+ if x_type not in ("ordinal", "nominal"):
2042
+ return None
2043
+
2044
+ font_family = dt_style.font.family
2045
+ font_size = dt_style.font.size
2046
+ if font_size is None:
2047
+ raise ValueError(
2048
+ "data_table.font.size must be set by the theme cascade; "
2049
+ "a missing value means the theme is misconfigured"
2050
+ )
2051
+ measurer = get_font_measurer(font_family, numeric=True)
2052
+
2053
+ entry_dx: list[float] = []
2054
+ for entry in data_table.entries:
2055
+ max_w = 0.0
2056
+ fmt = entry.format
2057
+
2058
+ if isinstance(entry, ChartDataTablePerSeries):
2059
+ # per_series entries render one cell per (x, series) — measure
2060
+ # widths over the same (x, color) grouping the VL aggregate
2061
+ # transform uses (data_table_attachment._per_series_row_layers).
2062
+ # Grouping by x alone would sum across series and inflate dx.
2063
+ source = entry.per_series
2064
+ values: list[float] = []
2065
+ if x_field is not None and color_field is not None:
2066
+ values = _aggregate_by_x_and_color(data, source, x_field, color_field)
2067
+ else:
2068
+ for row in data:
2069
+ raw = row.get(source)
2070
+ if raw is None:
2071
+ continue
2072
+ with contextlib.suppress(ValueError, TypeError):
2073
+ values.append(float(raw))
2074
+ elif isinstance(entry, ChartDataTableAggregate) and x_field is not None:
2075
+ # Pre-aggregate to match the VL aggregate transform in _row_text_layer.
2076
+ # Without this, dx is measured from raw per-row values which are
2077
+ # typically narrower than the per-x aggregate.
2078
+ source = entry.source
2079
+ values = _aggregate_by_x(data, source, x_field, entry.aggregate)
2080
+ else:
2081
+ source = entry.source
2082
+ # Bare source entries can point to string columns (Vega renders them
2083
+ # directly as labels). Non-numeric values simply contribute no width;
2084
+ # dx falls to 0 and the column is not band-centred — which is correct
2085
+ # because centering is only meaningful for a column of known numeric width.
2086
+ values = []
2087
+ for row in data:
2088
+ raw = row.get(source)
2089
+ if raw is None:
2090
+ continue
2091
+ with contextlib.suppress(ValueError, TypeError):
2092
+ values.append(float(raw))
2093
+
2094
+ for num in values:
2095
+ formatted = format_value(num, fmt, formats) if fmt else str(num)
2096
+ w = measurer.measure(formatted, font_size)
2097
+ if w > max_w:
2098
+ max_w = w
2099
+ entry_dx.append(max_w / 2.0)
2100
+
2101
+ return entry_dx if any(dx > 0 for dx in entry_dx) else None
2102
+
2103
+
2104
+ def _label_period_filter_expr(
2105
+ spec: dict[str, Any],
2106
+ resolved_chart_style: MergedChartsStyle,
2107
+ data: list[dict[str, Any]],
2108
+ x_field: str,
2109
+ chart_type: str,
2110
+ ) -> str | None:
2111
+ """Return a Vega filter expression restricting data_table cells to label-period openers.
2112
+
2113
+ Returns None when no filtering is needed: label cadence equals band cadence,
2114
+ no temporal axis, no coarser label cadence, or opens_label_period returns None
2115
+ for an unsupported encoding/label-cadence pair.
2116
+
2117
+ Semantics for aggregate: entries — the filter shows the per-opener-band
2118
+ aggregate, not a re-aggregated sum across all bands in the period. This is
2119
+ correct for the primary case (one source row per band), and re-aggregating
2120
+ across bands would require a different VL groupby granularity that changes
2121
+ what the agg op operates on. The filter matches the opener list used by
2122
+ labelExpr so every visible axis label has exactly one data_table cell.
2123
+
2124
+ For the temporal path (timeUnit in spec x encoding): uses opens_label_period
2125
+ to build a UTC-month/date predicate on the raw x field.
2126
+
2127
+ For the ordinal bucketed-time path (axis.values present): builds an indexof
2128
+ predicate against the precomputed tick-values list (the label-period openers
2129
+ already computed by ordinal_axis_values in profile.py).
2130
+ """
2131
+ from dataface.core.compile.style_cascade import resolved_axis_style
2132
+
2133
+ enc_x = spec.get("encoding", {}).get("x", {})
2134
+ x_type = enc_x.get("type")
2135
+
2136
+ # Resolve the chart-type-specific axis_x patch (Layer 3.5) so authored
2137
+ # label.time_unit values that land on chart.style.<type>.axis_x via
2138
+ # normalize_chart_local_style_dict are picked up.
2139
+ ct_style = getattr(resolved_chart_style, chart_type, None)
2140
+ ct_axis_x = getattr(ct_style, "axis_x", None) if ct_style is not None else None
2141
+
2142
+ # Temporal path: timeUnit present in spec x encoding.
2143
+ enc_time_unit = enc_x.get("timeUnit")
2144
+ if enc_time_unit:
2145
+ # vl_time_unit emits "utc<grain>" (e.g. "utcyearmonth") in the spec.
2146
+ # Strip that prefix so internal helpers that work with Dataface grain
2147
+ # names ("yearmonth", "yearweek", …) receive the right string.
2148
+ dft_time_unit = enc_time_unit.removeprefix("utc")
2149
+ axis_st = resolved_axis_style(
2150
+ resolved_chart_style, "axis_x", x_type or "temporal", ct_axis_x
2151
+ )
2152
+ label_tu = resolve_label_time_unit(dft_time_unit, axis_st.label.time_unit)
2153
+ if not label_tu or label_tu == dft_time_unit:
2154
+ return None
2155
+ safe_field = x_field.replace("'", "\\'")
2156
+ gate = opens_label_period(
2157
+ dft_time_unit,
2158
+ label_tu,
2159
+ v=f"toDate(datum['{safe_field}'])",
2160
+ month="utcmonth",
2161
+ date="utcdate",
2162
+ )
2163
+ return gate
2164
+
2165
+ # Ordinal bucketed-time path: axis.values is the precomputed opener list.
2166
+ if x_type == "ordinal":
2167
+ axis_vals = enc_x.get("axis", {}).get("values")
2168
+ if not axis_vals:
2169
+ return None
2170
+ x_distinct = {
2171
+ row.get(x_field)
2172
+ for row in data
2173
+ if x_field in row and row.get(x_field) is not None
2174
+ }
2175
+ if len(axis_vals) >= len(x_distinct):
2176
+ # No thinning needed: every band has a label.
2177
+ return None
2178
+ # Only filter when bands are too dense to show all cells without overlap.
2179
+ # At >= 45 px per band a formatted number fits; sparser data shows all cells.
2180
+ spec_width = spec.get("width")
2181
+ if isinstance(spec_width, (int, float)) and spec_width > 0:
2182
+ if spec_width / len(x_distinct) >= 45.0:
2183
+ return None
2184
+ safe_field = x_field.replace("'", "\\'")
2185
+ safe_vals = [
2186
+ str(v).replace("\\", "\\\\").replace("'", "\\'") for v in axis_vals
2187
+ ]
2188
+ quoted = ", ".join(f"'{v}'" for v in safe_vals)
2189
+ return f"indexof([{quoted}], datum['{safe_field}']) >= 0"
2190
+
2191
+ return None
2192
+
2193
+
2194
+ def _maybe_attach_data_table(
2195
+ spec: dict[str, Any],
2196
+ resolved_chart: ResolvedChart,
2197
+ data: list[dict[str, Any]],
2198
+ resolved_chart_style: MergedChartsStyle,
2199
+ padding: dict[str, Any] | None,
2200
+ chart_type: str,
2201
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
2202
+ """Post-pass: attach the data_table strip when the chart authors one.
2203
+
2204
+ Single source of truth for the validate → resolve-style → attach →
2205
+ reserve-padding sequence. Called from every render branch that needs
2206
+ to honor `chart.data_table` (single-mark, profile-driven layered,
2207
+ author-layered). Returns the (possibly mutated) spec plus the
2208
+ (possibly augmented) padding kwarg the caller forwards to _finalize.
2209
+
2210
+ Padding-bump destination depends on whether the caller supplied an
2211
+ external padding kwarg (which _finalize overwrites wholesale) or
2212
+ None (which leaves spec.padding alone).
2213
+ """
2214
+ data_table = resolved_chart.source_chart.data_table
2215
+ if data_table is None:
2216
+ return spec, padding
2217
+ # Extract the x encoding type from the already-built spec.
2218
+ x_type: str | None = spec.get("encoding", {}).get("x", {}).get("type")
2219
+ # For the validator, lex-sortable date-like ordinal axes (e.g. "2024-01")
2220
+ # behave like temporal — the window sort on the raw string field produces
2221
+ # chronological order for year-leading patterns — so sampling is safe.
2222
+ # Non-lex-sortable patterns (e.g. "Jan 2024", "01/2024") stay ordinal and
2223
+ # continue to fail-closed at >40 distinct values.
2224
+ # IMPORTANT: this reclassification is only for the validator. x_type (the
2225
+ # actual spec encoding type) is kept separate so centering still applies to
2226
+ # lex-sortable ordinal axes — they render with bandPosition:0.5, so they
2227
+ # need dx exactly like any other ordinal axis.
2228
+ validator_x_type = x_type
2229
+ if validator_x_type == "ordinal" and resolved_chart.x and data:
2230
+ # Require ALL non-null values to be lex-sortable date-like — one stray
2231
+ # "Unknown" mid-column must keep the axis ordinal (fail-closed).
2232
+ field = resolved_chart.x
2233
+ all_lex_sortable = True
2234
+ for row in data:
2235
+ v = row.get(field)
2236
+ if v is None:
2237
+ continue
2238
+ if not (isinstance(v, str) and is_lex_sortable_date_like(v)):
2239
+ all_lex_sortable = False
2240
+ break
2241
+ if all_lex_sortable:
2242
+ validator_x_type = "temporal"
2243
+ sampling_step = validate_data_table_against_data(
2244
+ data_table, resolved_chart.x, data, x_type=validator_x_type
2245
+ )
2246
+ dt_style = resolve_effective_data_table_style(resolved_chart_style, chart_type)
2247
+ has_time_unit = bool(spec.get("encoding", {}).get("x", {}).get("timeUnit"))
2248
+ # color_field used by both the dx computation (per_series cells are
2249
+ # measured per-(x, series) so dx reflects the actual rendered widths
2250
+ # rather than the across-series sum) and the series_order/palette
2251
+ # resolution below.
2252
+ color_field = effective_color_field(resolved_chart)
2253
+ # Compute per-entry dx so number-lane centres align with band midpoints.
2254
+ # Uses x_type (the spec encoding type) NOT validator_x_type, so lex-sortable
2255
+ # ordinal axes (which have bandPosition:0.5) still get the dx they need.
2256
+ entry_dx = _compute_data_table_entry_dx(
2257
+ data_table,
2258
+ data,
2259
+ dt_style,
2260
+ has_time_unit,
2261
+ x_field=resolved_chart.x,
2262
+ x_type=x_type,
2263
+ color_field=color_field,
2264
+ formats=resolved_chart_style.formats,
2265
+ )
2266
+ # Resolve series_order and series_palette for per_series entries.
2267
+ has_per_series = any(
2268
+ isinstance(e, ChartDataTablePerSeries) for e in data_table.entries
2269
+ )
2270
+ series_order: list[str] | None = None
2271
+ series_palette: list[str] | None = None
2272
+ if has_per_series:
2273
+ # Resolve series order so the strip row adjacent to the plot matches
2274
+ # the chart's visual stack segment at that edge (adjacency invariant).
2275
+ # The bar-vs-area distinction matters: Dataface emits
2276
+ # ``order: {field, sort: ascending}`` on **bar** encodings only,
2277
+ # which inverts VL's default and puts alpha-FIRST at the BOTTOM of
2278
+ # a stacked bar (alpha-LAST at the TOP). Stacked **area** charts
2279
+ # carry no such override, so VL's nominal default applies:
2280
+ # alpha-FIRST at the TOP, alpha-LAST at the BOTTOM.
2281
+ #
2282
+ # Strip-row order is keyed off the actual rendered stack direction,
2283
+ # not "is_stacked":
2284
+ # stacked bar → reverse-alphabetical (top=alpha-LAST)
2285
+ # stacked area → alphabetical (top=alpha-FIRST = VL default)
2286
+ # non-stacked, line → alphabetical (matches VL legend order)
2287
+ #
2288
+ # NOTE on position: this logic maintains the adjacency invariant
2289
+ # regardless of position. For position='bottom' the strip reads
2290
+ # top-to-bottom in the same direction as the visual stack. For
2291
+ # position='top' the strip is above the plot: layer index 0 (the row
2292
+ # adjacent to the plot top edge) carries alpha-LAST for a stacked
2293
+ # bar, so reading the strip top-to-bottom produces alpha-FIRST →
2294
+ # alpha-LAST — the reverse of the visual-stack direction. Adjacency
2295
+ # is preserved; top-to-bottom reading order is not (this is inherent
2296
+ # to placing the strip above the chart).
2297
+ if color_field and data:
2298
+ distinct: set[str] = set()
2299
+ for row in data:
2300
+ s = row.get(color_field)
2301
+ if s is not None:
2302
+ distinct.add(str(s))
2303
+ # Only stacked BAR carries Dataface's order:ascending override.
2304
+ # Grouped-by-default and explicit-false bars use alphabetical order.
2305
+ # Stacked area and other chart types use VL's nominal default.
2306
+ is_stacked_bar = (
2307
+ resolved_chart.chart_type == "bar"
2308
+ and resolved_chart.stack is not False
2309
+ and not is_grouped_bar(resolved_chart)
2310
+ )
2311
+ if is_stacked_bar:
2312
+ series_order = sorted(distinct, reverse=True)
2313
+ else:
2314
+ series_order = sorted(distinct)
2315
+ palette = list(resolved_chart_style.palette)
2316
+ # VL assigns palette[i] to the i-th series in alphabetical color
2317
+ # domain. Map each entry in series_order to the palette index of
2318
+ # its alphabetical position so reverse-alphabetical strips still
2319
+ # render each series in its actual chart color.
2320
+ alpha_index = {s: i for i, s in enumerate(sorted(series_order))}
2321
+ series_palette = [
2322
+ palette[alpha_index[s] % len(palette)] for s in series_order
2323
+ ]
2324
+ series_count = len(series_order) if series_order else 0
2325
+ period_filter = (
2326
+ _label_period_filter_expr(
2327
+ spec, resolved_chart_style, data, resolved_chart.x, chart_type
2328
+ )
2329
+ if resolved_chart.x
2330
+ else None
2331
+ )
2332
+ # When a period_filter is active it already restricts strip cells to
2333
+ # label-period openers (e.g. one per month on a daily-data chart).
2334
+ # Applying sampling on top would further thin the strip — e.g. daily
2335
+ # data with 365 rows gives sampling_step=10, then the period filter
2336
+ # keeps 12 monthly openers, and sampling reduces that to ~2 cells.
2337
+ # The period filter is the correct thinning mechanism; suppress sampling.
2338
+ if period_filter is not None:
2339
+ sampling_step = 1
2340
+ resolved_orient = _resolve_orient_auto(
2341
+ resolved_chart_style.axis_y.orient, resolved_chart, resolved_chart_style
2342
+ )
2343
+ if resolved_orient not in ("left", "right"):
2344
+ raise ValueError(
2345
+ f"axis_y.orient resolved to unexpected value {resolved_orient!r}; "
2346
+ "expected 'left' or 'right'"
2347
+ )
2348
+ axis_y_orient = cast(Literal["left", "right"], resolved_orient)
2349
+ spec = attach_data_table(
2350
+ spec,
2351
+ data_table=data_table,
2352
+ style=dt_style,
2353
+ charts_style=resolved_chart_style,
2354
+ sampling_step=sampling_step,
2355
+ entry_dx=entry_dx,
2356
+ series_order=series_order,
2357
+ series_palette=series_palette,
2358
+ label_period_filter_expr=period_filter,
2359
+ axis_y_orient=axis_y_orient,
2360
+ formats=resolved_chart_style.formats,
2361
+ )
2362
+ strip_h = data_table_strip_height(
2363
+ data_table, dt_style, resolved_chart_style, series_count=series_count
2364
+ )
2365
+ if strip_h > 0:
2366
+ if dt_style.position == "top":
2367
+ if padding is not None:
2368
+ padding = {
2369
+ **padding,
2370
+ "top": float(padding.get("top", 0)) + strip_h,
2371
+ }
2372
+ else:
2373
+ bump_padding_top(spec, strip_h)
2374
+ else:
2375
+ if padding is not None:
2376
+ padding = {
2377
+ **padding,
2378
+ "bottom": float(padding.get("bottom", 0)) + strip_h,
2379
+ }
2380
+ else:
2381
+ bump_padding_bottom(spec, strip_h)
2382
+ return spec, padding
2383
+
2384
+
2385
+ def render_standard_vega_spec(
2386
+ resolved_chart: ResolvedChart,
2387
+ data: list[dict[str, Any]],
2388
+ width: float | None = None,
2389
+ height: float | None = None,
2390
+ theme: str | None = None,
2391
+ custom_registry: CustomChartTypeRegistry | None = None,
2392
+ datasets: dict[str, list[dict[str, Any]]] | None = None,
2393
+ padding: dict[str, Any] | None = None,
2394
+ resolved_style: MergedStyle | None = None,
2395
+ face_level: int = 1,
2396
+ effective_vega_config: dict[str, Any] | None = None,
2397
+ ) -> dict[str, Any]:
2398
+ """Render a resolved chart to a Vega-Lite spec.
2399
+
2400
+ Custom chart types registered in ``custom_registry`` are resolved to
2401
+ their underlying Vega-Lite mark and rendered through the standard
2402
+ cartesian path.
2403
+
2404
+ ``resolved_style`` carries the face's resolved background so the chart's
2405
+ halo / knockout / spec-root background match the face's actual paper
2406
+ colour, not whatever the theme name lookup would produce.
2407
+
2408
+ ``face_level`` is the heading level of the parent face (root=1, nested=2, …).
2409
+ Chart title uses face_level + 1.
2410
+ """
2411
+ data = normalize_data_types(data)
2412
+ if resolved_chart.x:
2413
+ pre, x = data, resolved_chart.x
2414
+ data = normalize_labeled_temporal(data, x)
2415
+ if data is not pre:
2416
+ # normalize_labeled_temporal rewrote labeled strings to ISO; sort by ISO
2417
+ # so chronological order holds regardless of warehouse row order.
2418
+ data = sorted(data, key=lambda r: (r.get(x) is None, r.get(x)))
2419
+ validate_preaggregated_data(resolved_chart, data)
2420
+ chart_type = resolved_chart.chart_type
2421
+ resolved_chart_style = resolved_chart.resolved_style
2422
+ face_background_overlay = (
2423
+ resolved_style.background if resolved_style is not None else None
2424
+ )
2425
+
2426
+ if chart_type == "histogram":
2427
+ mapped = map_to_vega_lite(resolved_chart, data, width, theme)
2428
+ return _finalize(
2429
+ _generate_histogram_spec(mapped, resolved_chart, data, width, height),
2430
+ resolved_chart,
2431
+ theme,
2432
+ resolved_chart_style,
2433
+ width=width,
2434
+ padding=padding,
2435
+ face_background_overlay=face_background_overlay,
2436
+ face_level=face_level,
2437
+ effective_vega_config=effective_vega_config,
2438
+ )
2439
+ if chart_type == "boxplot":
2440
+ mapped = map_to_vega_lite(resolved_chart, data, width, theme)
2441
+ return _finalize(
2442
+ _generate_boxplot_spec(mapped, resolved_chart, data, width, height),
2443
+ resolved_chart,
2444
+ theme,
2445
+ resolved_chart_style,
2446
+ width=width,
2447
+ padding=padding,
2448
+ face_background_overlay=face_background_overlay,
2449
+ face_level=face_level,
2450
+ effective_vega_config=effective_vega_config,
2451
+ )
2452
+ if chart_type in ("errorbar", "errorband"):
2453
+ mapped = map_to_vega_lite(resolved_chart, data, width, theme)
2454
+ return _finalize(
2455
+ _generate_error_spec(mapped, resolved_chart, data, width, height),
2456
+ resolved_chart,
2457
+ theme,
2458
+ resolved_chart_style,
2459
+ width=width,
2460
+ padding=padding,
2461
+ face_background_overlay=face_background_overlay,
2462
+ face_level=face_level,
2463
+ effective_vega_config=effective_vega_config,
2464
+ )
2465
+ if chart_type in ("arc", "pie"):
2466
+ mapped = map_to_vega_lite(resolved_chart, data, width, theme)
2467
+ return _finalize(
2468
+ _generate_arc_spec(mapped, resolved_chart, data, width, height),
2469
+ resolved_chart,
2470
+ theme,
2471
+ resolved_chart_style,
2472
+ width=width,
2473
+ padding=padding,
2474
+ face_background_overlay=face_background_overlay,
2475
+ face_level=face_level,
2476
+ effective_vega_config=effective_vega_config,
2477
+ )
2478
+ if chart_type in ("rect", "square", "heatmap"):
2479
+ mapped = map_to_vega_lite(resolved_chart, data, width, theme)
2480
+ return _finalize(
2481
+ _generate_rect_spec(mapped, resolved_chart, data, width, height),
2482
+ resolved_chart,
2483
+ theme,
2484
+ resolved_chart_style,
2485
+ width=width,
2486
+ padding=padding,
2487
+ face_background_overlay=face_background_overlay,
2488
+ face_level=face_level,
2489
+ effective_vega_config=effective_vega_config,
2490
+ )
2491
+ if chart_type in ("map", "geoshape"):
2492
+ if resolved_style is None:
2493
+ raise ValueError(
2494
+ f"render_standard_vega_spec requires resolved_style for chart type '{chart_type}'. "
2495
+ "Thread the face's MergedStyle from the render pipeline."
2496
+ )
2497
+ return _finalize(
2498
+ _generate_map_spec(
2499
+ resolved_chart,
2500
+ data,
2501
+ chart_type,
2502
+ width,
2503
+ height,
2504
+ board_style=resolved_style,
2505
+ ),
2506
+ resolved_chart,
2507
+ theme,
2508
+ resolved_chart_style,
2509
+ width=width,
2510
+ padding=padding,
2511
+ face_background_overlay=face_background_overlay,
2512
+ face_level=face_level,
2513
+ effective_vega_config=effective_vega_config,
2514
+ )
2515
+ if chart_type in ("point_map", "bubble_map"):
2516
+ if resolved_style is None:
2517
+ raise ValueError(
2518
+ f"render_standard_vega_spec requires resolved_style for chart type '{chart_type}'. "
2519
+ "Thread the face's MergedStyle from the render pipeline."
2520
+ )
2521
+ return _finalize(
2522
+ _generate_point_map_spec(
2523
+ resolved_chart,
2524
+ data,
2525
+ chart_type,
2526
+ width,
2527
+ height,
2528
+ board_style=resolved_style,
2529
+ ),
2530
+ resolved_chart,
2531
+ theme,
2532
+ resolved_chart_style,
2533
+ width=width,
2534
+ padding=padding,
2535
+ face_background_overlay=face_background_overlay,
2536
+ face_level=face_level,
2537
+ effective_vega_config=effective_vega_config,
2538
+ )
2539
+
2540
+ if chart_type == "layered" or isinstance(resolved_chart.y, list):
2541
+ spec = build_layered_series_spec(
2542
+ resolved_chart, data, width, height, theme, datasets=datasets
2543
+ )
2544
+ spec, padding = _maybe_attach_data_table(
2545
+ spec, resolved_chart, data, resolved_chart_style, padding, chart_type
2546
+ )
2547
+ return _finalize(
2548
+ spec,
2549
+ resolved_chart,
2550
+ theme,
2551
+ resolved_chart_style,
2552
+ width=width,
2553
+ padding=padding,
2554
+ face_background_overlay=face_background_overlay,
2555
+ face_level=face_level,
2556
+ effective_vega_config=effective_vega_config,
2557
+ )
2558
+
2559
+ if chart_type not in CHART_TYPE_MAP and not (
2560
+ custom_registry and custom_registry.get(chart_type)
2561
+ ):
2562
+ from dataface.core.errors import DF_RENDER_UNKNOWN_CHART_TYPE
2563
+
2564
+ raise UnknownChartType.from_code(
2565
+ DF_RENDER_UNKNOWN_CHART_TYPE,
2566
+ chart_type=chart_type,
2567
+ available=", ".join(sorted(CHART_TYPE_MAP.keys())),
2568
+ )
2569
+
2570
+ # ── Standard single-metric path: profile mapping → mechanical emit ──
2571
+ background = _resolve_effective_background(
2572
+ resolved_chart_style, theme, face_background_overlay=face_background_overlay
2573
+ )
2574
+ mapped = map_to_vega_lite(
2575
+ resolved_chart,
2576
+ data,
2577
+ width,
2578
+ theme,
2579
+ custom_registry=custom_registry,
2580
+ background=background,
2581
+ )
2582
+
2583
+ if mapped.layers:
2584
+ spec = _generate_layered_spec(mapped, resolved_chart, data, width, height)
2585
+ spec, padding = _maybe_attach_data_table(
2586
+ spec, resolved_chart, data, resolved_chart_style, padding, chart_type
2587
+ )
2588
+ return _finalize(
2589
+ spec,
2590
+ resolved_chart,
2591
+ theme,
2592
+ resolved_chart_style,
2593
+ width=width,
2594
+ padding=padding,
2595
+ data=data,
2596
+ face_background_overlay=face_background_overlay,
2597
+ face_level=face_level,
2598
+ effective_vega_config=effective_vega_config,
2599
+ )
2600
+
2601
+ # Use gap-filled data when the profile synthesized missing time-bucket rows.
2602
+ spec_data = mapped.data_override if mapped.data_override is not None else data
2603
+ render_height = height
2604
+ # Only grow within a layout-allocated slot. Omitting height keeps Vega
2605
+ # autosize and preserves the data_table contract (explicit height required).
2606
+ if (
2607
+ chart_type == "bar"
2608
+ and resolved_chart.orientation == "horizontal"
2609
+ and spec_data
2610
+ and height is not None
2611
+ and height > 0
2612
+ ):
2613
+ n_categories = count_horizontal_bar_categories(resolved_chart.x, spec_data)
2614
+ min_h = min_height_for_horizontal_bar_categories(
2615
+ n_categories, resolved_chart_style
2616
+ )
2617
+ if min_h > 0:
2618
+ render_height = max(height, min_h)
2619
+ spec = build_base_spec(spec_data, width, render_height)
2620
+ spec["mark"] = mapped.mark
2621
+ spec["encoding"] = dict(mapped.encoding)
2622
+ if mapped.transform:
2623
+ spec["transform"] = [dict(t) for t in mapped.transform]
2624
+ set_chart_title(
2625
+ spec,
2626
+ resolved_chart.title,
2627
+ resolved_chart.subtitle,
2628
+ style=resolved_chart_style,
2629
+ )
2630
+
2631
+ spec, padding = _maybe_attach_data_table(
2632
+ spec, resolved_chart, spec_data, resolved_chart_style, padding, chart_type
2633
+ )
2634
+ return _finalize(
2635
+ spec,
2636
+ resolved_chart,
2637
+ theme,
2638
+ resolved_chart_style,
2639
+ width=width,
2640
+ padding=padding,
2641
+ data=spec_data,
2642
+ face_background_overlay=face_background_overlay,
2643
+ face_level=face_level,
2644
+ effective_vega_config=effective_vega_config,
2645
+ )