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,916 @@
1
+ """Custom SVG renderer for KPI quantitative-text-object charts.
2
+
3
+ KPI is rendered as a left-aligned three-element typographic object — no
4
+ default card chrome:
5
+
6
+ 1. Value (large primary line, optionally prefixed by a glyph)
7
+ 2. Label (Inter 14, dark, may wrap to two lines)
8
+ 3. Support (optional: glyph + value + neutral trailing explainer)
9
+
10
+ Layout uses fixed internal slots so that labels wrapping to two lines do
11
+ not push neighbouring KPIs' value baselines out of alignment when several
12
+ KPIs sit side-by-side in a row.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import html
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from dataface.core.compile.colors import sanitize_color
22
+ from dataface.core.compile.config import get_chart_rendering
23
+ from dataface.core.compile.models.chart.authored import (
24
+ KpiSupportConfig,
25
+ coerce_numeric,
26
+ match_predicate,
27
+ )
28
+ from dataface.core.compile.models.primitives import FormatConfig
29
+ from dataface.core.compile.models.style.compiled import (
30
+ VALID_FONT_WEIGHTS,
31
+ font_weight_as_css,
32
+ )
33
+ from dataface.core.compile.models.style.merged import MergedChartsStyle, MergedStyle
34
+ from dataface.core.render.board_links import get_link_context, resolve_href
35
+ from dataface.core.render.chart.table_support import (
36
+ compute_scale_domain,
37
+ interpolate_scale_color,
38
+ resolve_hinge,
39
+ resolve_palette_stops,
40
+ )
41
+ from dataface.core.render.chart.title_overflow import (
42
+ compute_title_limit,
43
+ prepare_title_text,
44
+ resolve_title_overflow,
45
+ )
46
+ from dataface.core.render.errors import ChartDataError
47
+ from dataface.core.render.format_utils import format_kpi_parts, resolve_format
48
+
49
+ _VALID_TONES = ("positive", "negative", "warning")
50
+
51
+ # Typographic ratios for KPI geometry. Tuned by eye against the playground
52
+ # specimen.
53
+ #
54
+ # _CAP_HEIGHT_RATIO — fraction of font-size occupied by lining figures.
55
+ # Used for top-padding (visible whitespace from
56
+ # card edge to ink) and value→label gap math.
57
+ # _DESCENDER_RATIO — fraction of font-size occupied below baseline.
58
+ # Used to size the support row's bottom gutter so
59
+ # visible bottom padding equals ``pad.bottom``.
60
+ # _AFFIX_ELEVATION — fraction of (value_font − affix_font) that the
61
+ # prefix/glyph baseline rides above the value
62
+ # baseline. Single coefficient; affix and glyph
63
+ # pick up different absolute elevations because
64
+ # their size deltas differ. The 0.37 value is
65
+ # empirical (eyeballed against the specimen);
66
+ # it does not correspond to a typographic
67
+ # landmark like cap-top or midpoint.
68
+ _CAP_HEIGHT_RATIO = 0.7
69
+ _DESCENDER_RATIO = 0.2
70
+ _AFFIX_ELEVATION = 0.37
71
+
72
+
73
+ def _tone_color(tone: str | None, tones: Any) -> str | None:
74
+ """Resolve a semantic tone name to the theme-provided color.
75
+
76
+ ``tones`` is the ``KpiTonesStyle`` from ``resolved_style.kpi.tones``
77
+ — the renderer reads tone hexes from theme YAML rather than hardcoding
78
+ them so themes can rebrand the semantic vocabulary.
79
+ """
80
+ if tone is None:
81
+ return None
82
+ if tone not in _VALID_TONES:
83
+ raise ValueError(
84
+ f"KPI: unknown tone {tone!r}. Expected one of {list(_VALID_TONES)}."
85
+ )
86
+ return getattr(tones, tone)
87
+
88
+
89
+ def _rule_output_for_channel(rule: Any, channel_name: str) -> Any:
90
+ """Extract the style value from a ConditionalRule for a given KPI channel."""
91
+ if channel_name == "background":
92
+ return rule.background
93
+ if channel_name == "color":
94
+ return rule.font.color if rule.font is not None else None
95
+ return None
96
+
97
+
98
+ def _evaluate_channel_for_row(
99
+ resolved_channels: dict[str, Any],
100
+ channel_name: str,
101
+ row: dict[str, Any],
102
+ fallback: str | None = None,
103
+ col_format: str | None = None,
104
+ ) -> str | None:
105
+ """Evaluate a style channel against a single data row."""
106
+ ch = resolved_channels.get(channel_name)
107
+ if ch is None:
108
+ return fallback
109
+
110
+ if ch.mode == "literal":
111
+ return ch.literal_value
112
+
113
+ if ch.mode == "conditional":
114
+ cell_value = row.get(ch.data_field)
115
+ matched: Any = fallback
116
+ matched_any = False
117
+ default_rule: Any = None
118
+ for rule in ch.rules:
119
+ if rule.default is True:
120
+ default_rule = rule
121
+ continue
122
+ if match_predicate(rule, cell_value):
123
+ output = _rule_output_for_channel(rule, channel_name)
124
+ if output is not None:
125
+ matched = output
126
+ matched_any = True
127
+ if not matched_any and default_rule is not None:
128
+ output = _rule_output_for_channel(default_rule, channel_name)
129
+ if output is not None:
130
+ matched = output
131
+ # When no threshold rule matched and a fallback gradient scale is present,
132
+ # evaluate the gradient — this is the "scale shows through otherwise"
133
+ # behaviour that mirrors Looker's threshold-over-scale priority.
134
+ if matched is fallback and ch.fallback_scale is not None:
135
+ numeric = coerce_numeric(cell_value)
136
+ if numeric is None:
137
+ return ch.fallback_scale.null_color or fallback
138
+ lo, hi = compute_scale_domain([row], ch.data_field, ch.fallback_scale)
139
+ hinge = resolve_hinge(ch.fallback_scale, lo, hi, col_format)
140
+ return interpolate_scale_color(
141
+ numeric,
142
+ lo,
143
+ hi,
144
+ resolve_palette_stops(ch.fallback_scale.palette),
145
+ hinge=hinge,
146
+ arm_mode=ch.fallback_scale.arm_mode,
147
+ )
148
+ return matched
149
+
150
+ if ch.mode == "gradient":
151
+ scale = ch.scale
152
+ if scale is None:
153
+ return fallback
154
+ numeric = coerce_numeric(row.get(ch.data_field))
155
+ if numeric is None:
156
+ return scale.null_color or fallback
157
+ lo, hi = compute_scale_domain([row], ch.data_field, scale)
158
+ hinge = resolve_hinge(scale, lo, hi, col_format)
159
+ return interpolate_scale_color(
160
+ numeric,
161
+ lo,
162
+ hi,
163
+ resolve_palette_stops(scale.palette),
164
+ hinge=hinge,
165
+ arm_mode=scale.arm_mode,
166
+ )
167
+
168
+ return fallback
169
+
170
+
171
+ def _resolve_value(raw: str, row: dict[str, Any], chart_id: str) -> tuple[Any, str]:
172
+ """Return ``(cell_value, column_name)`` for a KPI/support block.
173
+
174
+ ``raw`` must be a column reference. Raises ``ChartDataError`` when the
175
+ column is not present in the query result row.
176
+ """
177
+ if raw in row:
178
+ return row[raw], raw
179
+ raise ChartDataError(
180
+ f"KPI value column '{raw}' not found in query result.\n"
181
+ "Channels are always column references; constant values come from the query.\n"
182
+ "For a string status value:\n"
183
+ f" query:\n"
184
+ f" rows:\n"
185
+ f' - status: "{raw}"\n'
186
+ f" value: status\n"
187
+ "For a numeric literal:\n"
188
+ " query:\n"
189
+ " rows:\n"
190
+ " - count: <value>\n"
191
+ " value: count",
192
+ chart_id=chart_id,
193
+ )
194
+
195
+
196
+ def _coerce_numeric_value(cell: Any) -> float | None:
197
+ """Coerce ``cell`` to a float or return None if it should be rendered as a string.
198
+
199
+ ``bool`` is treated as a string-class value even though it is technically
200
+ an ``int`` subclass — rendering ``True`` through the narrative-notation
201
+ pipeline as ``1.0`` would be magic behavior the author did not ask for.
202
+ """
203
+ if cell is None or isinstance(cell, bool):
204
+ return None
205
+ if isinstance(cell, (int, float)):
206
+ return float(cell)
207
+ try:
208
+ return float(cell)
209
+ except (ValueError, TypeError):
210
+ # String literals like "At risk" stay as strings.
211
+ return None
212
+
213
+
214
+ def _narrative_format_default(
215
+ format_input: str | FormatConfig | dict[str, Any] | None,
216
+ ) -> str | FormatConfig | dict[str, Any] | None:
217
+ """Apply KPI's narrative defaults when the author did not pick them.
218
+
219
+ KPI is a hero number that the reader pauses on, so it defaults to the
220
+ narrative notation register (``1.5mn``) instead of the analytic register
221
+ (``1.5 M``) used on axis ticks and table cells. When neither spec nor
222
+ notation is authored, we also default to compact SI form (``".2s"``)
223
+ so narrative notation actually shows up — notation alone is a no-op
224
+ against a non-SI spec.
225
+ """
226
+ if format_input is None:
227
+ return FormatConfig(spec=".2s", notation="narrative")
228
+ if isinstance(format_input, FormatConfig):
229
+ spec = format_input.spec
230
+ notation = format_input.notation
231
+ updates: dict[str, Any] = {}
232
+ if notation is None:
233
+ updates["notation"] = "narrative"
234
+ if spec is None and (notation is None or notation in ("narrative", "analytic")):
235
+ updates["spec"] = ".2s"
236
+ return format_input.model_copy(update=updates) if updates else format_input
237
+ if isinstance(format_input, dict):
238
+ spec = format_input.get("spec")
239
+ notation = format_input.get("notation")
240
+ updated = dict(format_input)
241
+ if notation is None:
242
+ updated["notation"] = "narrative"
243
+ if spec is None and (notation is None or notation in ("narrative", "analytic")):
244
+ updated["spec"] = ".2s"
245
+ return updated
246
+ # Plain string spec (e.g. ",.0f"): respect the author's spec; just add
247
+ # narrative notation so any SI-form spec they used renders narratively.
248
+ return FormatConfig(spec=str(format_input), notation="narrative")
249
+
250
+
251
+ def _format_value_parts(
252
+ cell: Any,
253
+ format_input: str | FormatConfig | dict[str, Any] | None,
254
+ formats: dict[str, Any] | None = None,
255
+ ) -> tuple[str, str, str, bool]:
256
+ """Format a KPI cell into ``(prefix, number_str, suffix, is_numeric)``.
257
+
258
+ Non-numeric cells are returned as a string in ``number_str`` with empty
259
+ affixes — used for status-style KPIs like ``"At risk"``.
260
+ """
261
+ numeric = _coerce_numeric_value(cell)
262
+ if numeric is None:
263
+ text = "" if cell is None else str(cell)
264
+ return "", text, "", False
265
+ prefix, number_str, suffix = format_kpi_parts(numeric, format_input, formats)
266
+ return prefix, number_str, suffix, True
267
+
268
+
269
+ def _resolved_charts_style_value(
270
+ chart: Any, fallback_style: MergedChartsStyle, key: str
271
+ ) -> Any:
272
+ """Read a top-level chart-style field from the merged MergedChartsStyle.
273
+
274
+ Chart-local Patch fields (background, color, title) are pre-merged into
275
+ ``chart.resolved_style`` by build_resolved_style. In the rare test path
276
+ where ``chart`` is a Mock without ``resolved_style``, fall back to the
277
+ ``fallback_style`` already passed to render_kpi_svg for the chart-level
278
+ merged style.
279
+ """
280
+ rs = getattr(chart, "resolved_style", None) or fallback_style
281
+ return getattr(rs, key)
282
+
283
+
284
+ def _explicit_color_override(value: Any) -> str | None:
285
+ if value in (None, ""):
286
+ return None
287
+ return sanitize_color(str(value), None)
288
+
289
+
290
+ def _resolve_value_color(
291
+ tone: str | None,
292
+ channel_color: str | None,
293
+ style_color: str | None,
294
+ tones: Any,
295
+ fallback: str,
296
+ ) -> str:
297
+ """Resolve KPI value color with deterministic precedence.
298
+
299
+ Highest → lowest:
300
+
301
+ 1. ``channel_color`` — data-driven color from ``conditional_formatting``
302
+ 2. ``tone`` — semantic shorthand (``positive`` → green, etc.)
303
+ 3. ``style.color`` or ``style.kpi.value.font.color`` — chart-local style color
304
+ 4. theme ``kpi.value.font.color`` — last-resort ink
305
+ """
306
+ if channel_color is not None:
307
+ return channel_color
308
+ tone_color = _tone_color(tone, tones)
309
+ if tone_color is not None:
310
+ return tone_color
311
+ if style_color:
312
+ return sanitize_color(style_color, fallback)
313
+ return fallback
314
+
315
+
316
+ def _resolve_glyph_color(
317
+ tone: str | None,
318
+ tones: Any,
319
+ fallback: str,
320
+ ) -> str:
321
+ """Glyph color: tone > fallback (matches table behaviour)."""
322
+ tone_color = _tone_color(tone, tones)
323
+ if tone_color is not None:
324
+ return tone_color
325
+ return fallback
326
+
327
+
328
+ @dataclass(frozen=True)
329
+ class _KpiLayout:
330
+ """Vertical slot dimensions for the value / label / support layout."""
331
+
332
+ width: float
333
+ height: float
334
+ content_x: float
335
+ value_baseline: float
336
+ value_font: float
337
+ affix_font: float
338
+ glyph_font: float
339
+ value_weight: str
340
+ # Vertical baselines for affixes within the value line.
341
+ # Prefix ($ etc.) rides at cap height of the value (top-aligned).
342
+ # Glyph (▲▼●) sits at vertical midpoint. Suffix stays on the value baseline.
343
+ prefix_baseline: float
344
+ glyph_baseline: float
345
+ label_baseline_first: float
346
+ label_font_size: float
347
+ label_font_family: str
348
+ label_weight: str
349
+ label_line_height: float
350
+ label_lines: tuple[str, ...]
351
+ label_original: str
352
+ label_truncated: bool # True when rendered label differs from original
353
+ support_baseline: float
354
+ support_font_size: float
355
+ # None = inherit body weight (browser/document default). Set explicitly
356
+ # (e.g. "500") to override — wired from ``kpi.font.weight``.
357
+ support_weight: str | None
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class _KpiColors:
362
+ """Resolved fill colors for the value / glyph / label / card surfaces."""
363
+
364
+ value_fill: str
365
+ glyph_fill: str
366
+ label_fill: str
367
+ card_fill: str | None
368
+ border_color: str | None
369
+ muted: str
370
+
371
+
372
+ @dataclass(frozen=True)
373
+ class _SupportRow:
374
+ """Resolved support-row content + colors. ``None`` means no row to emit."""
375
+
376
+ glyph: str
377
+ value_str: str
378
+ explainer: str
379
+ value_fill: str
380
+ glyph_fill: str
381
+
382
+
383
+ def _resolve_value_font_spec(kpi_config: Any) -> tuple[float, str, float, float]:
384
+ """Return ``(value_font, value_weight, affix_font, glyph_font)``.
385
+
386
+ Font sizes come from authored theme YAML (kpi.value.font.size, etc.).
387
+ No clamping — theme sets the exact size.
388
+ """
389
+ assert (
390
+ kpi_config.value.font.size is not None
391
+ ), "theme must supply kpi.value.font.size"
392
+ value_font = float(kpi_config.value.font.size)
393
+
394
+ weight_input = kpi_config.value.font.weight
395
+ value_weight = (
396
+ font_weight_as_css(weight_input)
397
+ if weight_input is not None
398
+ and font_weight_as_css(weight_input) in VALID_FONT_WEIGHTS
399
+ else "500"
400
+ )
401
+
402
+ assert (
403
+ kpi_config.affix.font.size is not None
404
+ ), "theme must supply kpi.affix.font.size"
405
+ affix_font = float(kpi_config.affix.font.size)
406
+ assert (
407
+ kpi_config.glyph.font.size is not None
408
+ ), "theme must supply kpi.glyph.font.size"
409
+ glyph_font = float(kpi_config.glyph.font.size)
410
+ return value_font, value_weight, affix_font, glyph_font
411
+
412
+
413
+ def _wrap_label_lines(
414
+ label_text: str,
415
+ requested_width: float,
416
+ pad: Any,
417
+ label_font_size: float,
418
+ label_font_family: str,
419
+ title_style: Any,
420
+ ) -> tuple[tuple[str, ...], bool]:
421
+ """Run the title-overflow wrap and report ``(lines, truncated)``.
422
+
423
+ Direct comparison of rendered vs original catches every overflow mode
424
+ (clip, truncate, wrap-two); the "…" sniff misses clip mode which
425
+ shortens without ellipsis. Empty label short-circuits — the slot is
426
+ reserved by the layout but no `<text>` is emitted (see _emit_kpi_svg).
427
+ """
428
+ if not label_text:
429
+ return (), False
430
+ rendered_label = prepare_title_text(
431
+ label_text,
432
+ overflow=resolve_title_overflow(title_style),
433
+ limit=compute_title_limit(
434
+ requested_width,
435
+ {"left": pad.left, "right": pad.right},
436
+ ),
437
+ font_size=label_font_size,
438
+ font_family=label_font_family,
439
+ )
440
+ label_lines = tuple((rendered_label.splitlines() or [label_text])[:2])
441
+ return label_lines, rendered_label != label_text
442
+
443
+
444
+ def _resolve_kpi_layout(
445
+ label_text: str,
446
+ requested_width: float,
447
+ requested_height: float,
448
+ resolved_style: MergedChartsStyle,
449
+ ) -> _KpiLayout:
450
+ kpi_config = resolved_style.kpi
451
+ kpi_rendering = get_chart_rendering().kpi
452
+
453
+ # Label slot reads from kpi.label.font (cascade fills from kpi.font).
454
+ # Sizes and family are guaranteed non-None after resolve_style(); asserts
455
+ # confirm the cascade contract. Weight falls back to "500" for non-CSS values
456
+ # (e.g. theme supplies 701 which isn't a valid CSS weight keyword).
457
+ _label_size = kpi_config.label.font.size
458
+ _support_size = kpi_config.font.size
459
+ assert (
460
+ _label_size is not None
461
+ ), "kpi.label.font.size unresolved — call resolve_style() first"
462
+ assert (
463
+ _support_size is not None
464
+ ), "kpi.font.size unresolved — call resolve_style() first"
465
+ label_font_size = float(_label_size)
466
+ _lw = kpi_config.label.font.weight
467
+ label_weight: str = (
468
+ font_weight_as_css(_lw)
469
+ if _lw is not None and font_weight_as_css(_lw) in VALID_FONT_WEIGHTS
470
+ else "500"
471
+ )
472
+ _label_family = kpi_config.label.font.family
473
+ assert (
474
+ _label_family is not None
475
+ ), "kpi.label.font.family unresolved — call resolve_style() first"
476
+ label_font_family = _label_family
477
+ support_font_size = float(_support_size)
478
+ pad = kpi_config.content_padding
479
+
480
+ value_font, value_weight, affix_font, glyph_font = _resolve_value_font_spec(
481
+ kpi_config
482
+ )
483
+ # Support weight: when ``kpi.font.weight`` is set, emit it explicitly on
484
+ # the support <text>; otherwise leave it implicit (inherits body weight).
485
+ support_weight_input = kpi_config.font.weight
486
+ support_weight: str | None = (
487
+ font_weight_as_css(support_weight_input)
488
+ if support_weight_input is not None
489
+ and font_weight_as_css(support_weight_input) in VALID_FONT_WEIGHTS
490
+ else None
491
+ )
492
+
493
+ label_line_height = label_font_size + 4.0
494
+ label_top_padding = float(kpi_rendering.minimum_title_value_gap)
495
+
496
+ # Inner edge padding (per side) reads from ``kpi.content_padding``.
497
+ # Cap-height + descender awareness make ``pad.top`` / ``pad.bottom``
498
+ # mean *visible whitespace from card edge to ink* (not baseline-to-edge),
499
+ # so 18 vs 5 top/bottom asymmetry is gone.
500
+ cap_height = value_font * _CAP_HEIGHT_RATIO
501
+
502
+ # Value + label + support is a fixed 3-line text block at one consistent
503
+ # rhythm:
504
+ # line 1: label
505
+ # line 2: label continuation, or blank
506
+ # line 3: support, riding the third label baseline regardless of its
507
+ # own font size.
508
+ value_baseline = pad.top + cap_height
509
+ prefix_baseline = value_baseline - (value_font - affix_font) * _AFFIX_ELEVATION
510
+ glyph_baseline = value_baseline - (value_font - glyph_font) * _AFFIX_ELEVATION
511
+ # Cap-height-aware on the label side too: ``label_top_padding`` reads
512
+ # as visible whitespace from the value's bottom (digits sit on the
513
+ # baseline; lining figures don't descend) to the label's cap top.
514
+ # Adding ``label_font_size`` here would inflate the visible gap by
515
+ # ~30% — the formula now gives the config token its literal meaning.
516
+ label_cap_height = label_font_size * _CAP_HEIGHT_RATIO
517
+ label_baseline_first = value_baseline + label_top_padding + label_cap_height
518
+ # When title.overflow forces single-line rendering, no label can wrap to
519
+ # two lines, so the second reserved line is wasted vertical. Row-baseline
520
+ # alignment is preserved because the overflow mode is theme-wide — every
521
+ # KPI in the face shifts by the same amount.
522
+ overflow_mode = resolve_title_overflow(resolved_style.title)
523
+ label_slot_lines = 1 if overflow_mode in {"clip", "truncate"} else 2
524
+ support_baseline = label_baseline_first + label_slot_lines * label_line_height
525
+
526
+ # Visible bottom padding = pad.bottom from support text descender
527
+ # to card edge (matches the visible top padding above).
528
+ support_descender = support_font_size * _DESCENDER_RATIO
529
+ minimum_card_h = support_baseline + support_descender + pad.bottom
530
+ height = max(requested_height, minimum_card_h)
531
+
532
+ label_lines, label_truncated = _wrap_label_lines(
533
+ label_text,
534
+ requested_width,
535
+ pad,
536
+ label_font_size,
537
+ label_font_family,
538
+ resolved_style.title,
539
+ )
540
+
541
+ return _KpiLayout(
542
+ width=requested_width,
543
+ height=height,
544
+ content_x=pad.left,
545
+ value_baseline=value_baseline,
546
+ value_font=value_font,
547
+ affix_font=affix_font,
548
+ glyph_font=glyph_font,
549
+ value_weight=value_weight,
550
+ prefix_baseline=prefix_baseline,
551
+ glyph_baseline=glyph_baseline,
552
+ label_baseline_first=label_baseline_first,
553
+ label_font_size=label_font_size,
554
+ label_font_family=label_font_family,
555
+ label_weight=label_weight,
556
+ label_line_height=label_line_height,
557
+ label_lines=label_lines,
558
+ label_original=label_text,
559
+ label_truncated=label_truncated,
560
+ support_baseline=support_baseline,
561
+ support_font_size=support_font_size,
562
+ support_weight=support_weight,
563
+ )
564
+
565
+
566
+ def _resolve_kpi_colors(
567
+ chart: Any,
568
+ row: dict[str, Any],
569
+ main_format: Any,
570
+ resolved_style: MergedChartsStyle,
571
+ formats: dict[str, str] | None = None,
572
+ *,
573
+ board_style: MergedStyle,
574
+ ) -> _KpiColors:
575
+ """Resolve the four surface fills (value/glyph/title/card) and border."""
576
+ kpi_config = resolved_style.kpi
577
+ _ms = board_style
578
+
579
+ resolved_channels = chart.resolved_channels
580
+ kpi_format = resolve_format(main_format, formats) or None
581
+ channel_bg = _evaluate_channel_for_row(
582
+ resolved_channels, "background", row, col_format=kpi_format
583
+ )
584
+ channel_color = _evaluate_channel_for_row(
585
+ resolved_channels, "color", row, col_format=kpi_format
586
+ )
587
+ card_fill = (
588
+ channel_bg
589
+ if channel_bg is not None
590
+ else _explicit_color_override(
591
+ _resolved_charts_style_value(chart, resolved_style, "background")
592
+ )
593
+ )
594
+
595
+ tones = kpi_config.tones
596
+ style_color = _resolved_charts_style_value(chart, resolved_style, "color")
597
+ tone = resolved_style.kpi.tone
598
+ value_fill = _resolve_value_color(
599
+ tone=tone,
600
+ channel_color=channel_color,
601
+ style_color=style_color if isinstance(style_color, str) else None,
602
+ tones=tones,
603
+ fallback=_ms.font.color,
604
+ )
605
+ glyph_fill = _resolve_glyph_color(tone, tones, fallback=value_fill)
606
+
607
+ # Label color: use the body text color directly. The legacy "label
608
+ # picks up style.title.font.color" coupling tied two distinct slots
609
+ # together (chart-section title and KPI label) and only worked by
610
+ # reading the authored Patch — i.e. by discriminating "did the chart
611
+ # author override style.title.font.color." After the cascade rename,
612
+ # resolved_style.title.font.color is always populated by the theme
613
+ # default, so the legacy fallthrough to body color silently flipped
614
+ # to a different theme value. Drop the coupling: the KPI label uses
615
+ # body text color (theme charts.color / sanitized fallback), and a
616
+ # future task can add a typed ``style.label.font.color`` slot if a
617
+ # KPI label override is genuinely needed.
618
+ label_fill = sanitize_color(None, _ms.font.color)
619
+
620
+ rs = getattr(chart, "resolved_style", None) or resolved_style
621
+ _bc = rs.border.color
622
+ border_color = (
623
+ _explicit_color_override(_bc)
624
+ if _bc.lower() not in {"transparent", "none", ""}
625
+ else None
626
+ )
627
+
628
+ return _KpiColors(
629
+ value_fill=value_fill,
630
+ glyph_fill=glyph_fill,
631
+ label_fill=label_fill,
632
+ card_fill=card_fill,
633
+ border_color=border_color,
634
+ muted=_ms.variables.font.color or "",
635
+ )
636
+
637
+
638
+ def _resolve_support_row(
639
+ support: KpiSupportConfig | None,
640
+ row: dict[str, Any],
641
+ tones: Any,
642
+ muted: str,
643
+ chart_id: str,
644
+ formats: dict[str, str] | None = None,
645
+ ) -> _SupportRow | None:
646
+ """Resolve the support row's text and colors. Returns None when the row
647
+ is absent or carries no content to emit."""
648
+ if support is None:
649
+ return None
650
+ s_cell = None
651
+ if support.value is not None:
652
+ s_cell, _ = _resolve_value(support.value, row, chart_id)
653
+ # Support keeps the BI default register — its dense numeric form
654
+ # belongs alongside axis ticks and table cells, not the hero number
655
+ # above it.
656
+ s_prefix, s_number_str, s_suffix, _ = _format_value_parts(
657
+ s_cell, support.format, formats
658
+ )
659
+ if s_prefix or s_suffix:
660
+ value_str = f"{s_prefix}{s_number_str}{s_suffix}"
661
+ else:
662
+ value_str = s_number_str
663
+
664
+ glyph = support.glyph or ""
665
+ explainer = support.label or ""
666
+ if not (value_str or explainer or glyph):
667
+ return None
668
+
669
+ tone_color = _tone_color(support.tone, tones)
670
+ value_fill = tone_color if tone_color is not None else muted
671
+ glyph_fill = tone_color if tone_color is not None else value_fill
672
+
673
+ return _SupportRow(
674
+ glyph=glyph,
675
+ value_str=value_str,
676
+ explainer=explainer,
677
+ value_fill=value_fill,
678
+ glyph_fill=glyph_fill,
679
+ )
680
+
681
+
682
+ def render_kpi_svg(
683
+ chart: Any,
684
+ data: list[dict[str, Any]],
685
+ width: float | None = None,
686
+ height: float | None = None,
687
+ is_placeholder: bool = False,
688
+ *,
689
+ resolved_style: MergedChartsStyle,
690
+ face_level: int = 1,
691
+ board_style: MergedStyle,
692
+ ) -> str:
693
+ """Render a KPI as a left-aligned three-slot quantitative text object."""
694
+ _ = face_level # KPI charts have no title; level unused at this renderer tier
695
+ kpi_config = resolved_style.kpi
696
+
697
+ chart_id = getattr(chart, "id", "unknown")
698
+ requested_w: float = width or kpi_config.default_width
699
+ requested_h: float = height or kpi_config.default_height
700
+
701
+ if not data:
702
+ raise ChartDataError(
703
+ f"KPI chart '{chart_id}' has no data — query returned 0 rows",
704
+ chart_id=chart_id,
705
+ )
706
+ if len(data) > 1:
707
+ from dataface.core.errors import DF_RENDER_KPI_MULTIROW
708
+
709
+ raise ChartDataError.from_code(
710
+ DF_RENDER_KPI_MULTIROW,
711
+ chart_id=chart_id,
712
+ row_count=len(data),
713
+ )
714
+
715
+ raw_value = chart.value
716
+ if raw_value is None:
717
+ raise ChartDataError(
718
+ f"KPI chart '{chart_id}' requires a 'value' field (column reference).",
719
+ chart_id=chart_id,
720
+ )
721
+
722
+ row = data[0]
723
+ cell, value_column = _resolve_value(raw_value, row, chart_id)
724
+ # Apply narrative-notation default to the top-level KPI block only —
725
+ # support-block format defaults to analytic (its register matches dense
726
+ # axis/table use rather than the hero number above it).
727
+ main_format = _narrative_format_default(chart.format)
728
+ formats = resolved_style.formats
729
+ prefix, number_str, suffix, value_is_numeric = _format_value_parts(
730
+ cell, main_format, formats
731
+ )
732
+ # Empty string honored as "no label" — renderer skips emission while
733
+ # keeping the slot reserved (so multi-up KPI rows stay aligned).
734
+ label_text = chart.label or ""
735
+ if label_text:
736
+ from dataface.core.render.text.case import apply_case
737
+
738
+ _label_case = kpi_config.label.font.case
739
+ if _label_case is not None and _label_case != "none":
740
+ label_text = apply_case(label_text, _label_case)
741
+
742
+ layout = _resolve_kpi_layout(label_text, requested_w, requested_h, resolved_style)
743
+ palette = _resolve_kpi_colors(
744
+ chart, row, main_format, resolved_style, formats, board_style=board_style
745
+ )
746
+ support_row = _resolve_support_row(
747
+ chart.support, row, kpi_config.tones, palette.muted, chart_id, formats
748
+ )
749
+
750
+ # Family resolution for KPI text. The cascade fills ``kpi.font.family``
751
+ # from root ``style.font.family`` and ``kpi.value.font.family`` from
752
+ # ``kpi.font.family``. Read the resolved values directly. If a caller
753
+ # bypasses the cascade and leaves either field None, the theme/contract
754
+ # is broken — raise loudly rather than emit ``font-family="None"``.
755
+ body_font_family = kpi_config.font.family
756
+ value_font_family = kpi_config.value.font.family
757
+ if body_font_family is None or value_font_family is None:
758
+ raise ValueError(
759
+ "KPI font family is unresolved — call resolve_style() so the "
760
+ "cascade fills kpi.font.family from style.font.family and "
761
+ "kpi.value.font.family from kpi.font.family before rendering."
762
+ )
763
+
764
+ return _emit_kpi_svg(
765
+ chart=chart,
766
+ layout=layout,
767
+ palette=palette,
768
+ support_row=support_row,
769
+ prefix=prefix,
770
+ number_str=number_str,
771
+ suffix=suffix,
772
+ value_is_numeric=value_is_numeric,
773
+ value_font_family=value_font_family,
774
+ body_font_family=body_font_family,
775
+ kpi_config=kpi_config,
776
+ )
777
+
778
+
779
+ def _emit_kpi_svg(
780
+ chart: Any,
781
+ layout: _KpiLayout,
782
+ palette: _KpiColors,
783
+ support_row: _SupportRow | None,
784
+ prefix: str,
785
+ number_str: str,
786
+ suffix: str,
787
+ value_is_numeric: bool,
788
+ value_font_family: str,
789
+ body_font_family: str,
790
+ kpi_config: Any,
791
+ ) -> str:
792
+ """Mechanical SVG assembly given pre-resolved layout/palette/support."""
793
+ parts: list[str] = []
794
+
795
+ if palette.card_fill is not None or palette.border_color is not None:
796
+ rect = (
797
+ f'<rect x="0" y="0" width="{layout.width}" height="{layout.height}" '
798
+ f'rx="{kpi_config.border.radius:g}" '
799
+ f'fill="{palette.card_fill or "none"}"'
800
+ )
801
+ if palette.border_color:
802
+ rect += f' stroke="{palette.border_color}" stroke-width="1"'
803
+ rect += "/>"
804
+ parts.append(rect)
805
+
806
+ # Value row — glyph (optional) + prefix + number + suffix. Per-tspan
807
+ # ``y=`` anchors each affix to its own baseline so prefix floats to the
808
+ # top of the line and glyph rides the middle, while value+suffix sit on
809
+ # the value baseline.
810
+ glyph_char = kpi_config.glyph.character
811
+ value_tspans: list[str] = []
812
+ if glyph_char:
813
+ value_tspans.append(
814
+ f'<tspan y="{layout.glyph_baseline}" '
815
+ f'font-size="{layout.glyph_font}" '
816
+ f'fill="{palette.glyph_fill}">{html.escape(glyph_char)} </tspan>'
817
+ )
818
+ if value_is_numeric and prefix:
819
+ value_tspans.append(
820
+ f'<tspan y="{layout.prefix_baseline}" '
821
+ f'font-size="{layout.affix_font}" '
822
+ f'fill="{palette.value_fill}">{html.escape(prefix)}</tspan>'
823
+ )
824
+ value_tspans.append(
825
+ f'<tspan y="{layout.value_baseline}" '
826
+ f'font-size="{layout.value_font}" '
827
+ f'fill="{palette.value_fill}">{html.escape(number_str)}</tspan>'
828
+ )
829
+ if value_is_numeric and suffix:
830
+ value_tspans.append(
831
+ f'<tspan y="{layout.value_baseline}" '
832
+ f'font-size="{layout.affix_font}" '
833
+ f'fill="{palette.value_fill}" dx="2">{html.escape(suffix)}</tspan>'
834
+ )
835
+ # font-weight on the parent <text> so glyph/prefix/value/suffix all inherit
836
+ # the same weight. The cascade resolves it from ``kpi.value.font.weight``.
837
+ _link_ctx = get_link_context()
838
+ kpi_link = (
839
+ resolve_href(chart.link, _link_ctx) if chart.link and _link_ctx else chart.link
840
+ )
841
+ if kpi_link:
842
+ escaped_href = html.escape(kpi_link, quote=True)
843
+ parts.append(f'<a href="{escaped_href}">')
844
+ parts.append(
845
+ f'<text x="{layout.content_x}" y="{layout.value_baseline}" '
846
+ f'text-anchor="start" font-family="{value_font_family}" '
847
+ f'font-weight="{layout.value_weight}">'
848
+ f'{"".join(value_tspans)}</text>'
849
+ )
850
+ if kpi_link:
851
+ parts.append("</a>")
852
+
853
+ # Label — fixed two-line slot, top-aligned within it. The slot is always
854
+ # reserved (so support baselines align across a row of mixed-length
855
+ # labels) even when the author set ``label: ""`` — only the <text> is
856
+ # skipped in that case.
857
+ if layout.label_original:
858
+ # Emit inner <title> when the rendered text differs from the
859
+ # original — catches all overflow modes (clip, truncate, wrap-two).
860
+ inner_title = (
861
+ f"<title>{html.escape(layout.label_original)}</title>"
862
+ if layout.label_truncated
863
+ else ""
864
+ )
865
+ parts.append(
866
+ f'<text x="{layout.content_x}" y="{layout.label_baseline_first}" '
867
+ f'text-anchor="start" font-family="{layout.label_font_family}" '
868
+ f'font-size="{layout.label_font_size}" fill="{palette.label_fill}" '
869
+ f'font-weight="{layout.label_weight}">'
870
+ f"{inner_title}"
871
+ )
872
+ for line_index, line in enumerate(layout.label_lines):
873
+ line_y = layout.label_baseline_first + line_index * layout.label_line_height
874
+ parts.append(
875
+ f'<tspan x="{layout.content_x}" y="{line_y}">{html.escape(line)}</tspan>'
876
+ )
877
+ parts.append("</text>")
878
+
879
+ # Support row — glyph + value + neutral explainer.
880
+ if support_row is not None:
881
+ s_tspans: list[str] = []
882
+ if support_row.glyph:
883
+ s_tspans.append(
884
+ f'<tspan fill="{support_row.glyph_fill}">'
885
+ f"{html.escape(support_row.glyph)} </tspan>"
886
+ )
887
+ if support_row.value_str:
888
+ s_tspans.append(
889
+ f'<tspan fill="{support_row.value_fill}">'
890
+ f"{html.escape(support_row.value_str)}</tspan>"
891
+ )
892
+ if support_row.explainer:
893
+ spacer = " " if support_row.value_str or support_row.glyph else ""
894
+ s_tspans.append(
895
+ f'<tspan fill="{palette.muted}">'
896
+ f"{html.escape(spacer + support_row.explainer)}</tspan>"
897
+ )
898
+ # Support always uses the body sans, never the value family — keeps
899
+ # editorial KPIs (serif value) reading right (sans support).
900
+ weight_attr = (
901
+ f' font-weight="{layout.support_weight}"' if layout.support_weight else ""
902
+ )
903
+ parts.append(
904
+ f'<text x="{layout.content_x}" y="{layout.support_baseline}" '
905
+ f'text-anchor="start" font-family="{body_font_family}" '
906
+ f'font-size="{layout.support_font_size}"{weight_attr}>'
907
+ f'{"".join(s_tspans)}</text>'
908
+ )
909
+
910
+ inner = "\n".join(parts)
911
+ return (
912
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
913
+ f'width="{layout.width}" height="{layout.height}" '
914
+ f'viewBox="0 0 {layout.width} {layout.height}">'
915
+ f"{inner}</svg>"
916
+ )