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.
- d3_format/__init__.py +14 -0
- d3_format/errors.py +19 -0
- d3_format/format.py +551 -0
- d3_format/spec.py +159 -0
- dataface/DATAFACE_SYNTAX.md +1135 -0
- dataface/__init__.py +93 -0
- dataface/_docs_site.py +20 -0
- dataface/_install_hint.py +26 -0
- dataface/agent_api/__init__.py +79 -0
- dataface/agent_api/_init_templates/__init__.py +0 -0
- dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
- dataface/agent_api/_init_templates/dataface.yml +15 -0
- dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
- dataface/agent_api/_init_templates/index.md +24 -0
- dataface/agent_api/_paths.py +118 -0
- dataface/agent_api/_project_agents_md.py +43 -0
- dataface/agent_api/_session_store.py +486 -0
- dataface/agent_api/_state.py +28 -0
- dataface/agent_api/chat.py +221 -0
- dataface/agent_api/dashboards.py +257 -0
- dataface/agent_api/describe.py +366 -0
- dataface/agent_api/describe_query.py +120 -0
- dataface/agent_api/docs/__init__.py +25 -0
- dataface/agent_api/docs/_loader.py +292 -0
- dataface/agent_api/docs/yaml-reference.md +2757 -0
- dataface/agent_api/file_refs.py +118 -0
- dataface/agent_api/init.py +126 -0
- dataface/agent_api/inspect.py +128 -0
- dataface/agent_api/mcp_install.py +170 -0
- dataface/agent_api/query.py +274 -0
- dataface/agent_api/schema.py +658 -0
- dataface/agent_api/schema_search.py +284 -0
- dataface/agent_api/search.py +270 -0
- dataface/agent_api/skill_install.py +141 -0
- dataface/agent_api/skill_render.py +90 -0
- dataface/agent_api/skills.py +293 -0
- dataface/agent_api/surface_aliases.yaml +128 -0
- dataface/agent_api/validate.py +175 -0
- dataface/agent_api/validate_query.py +84 -0
- dataface/ai/__init__.py +39 -0
- dataface/ai/agent.py +139 -0
- dataface/ai/context.py +45 -0
- dataface/ai/events.py +62 -0
- dataface/ai/external_mcp.py +610 -0
- dataface/ai/generate_sql.py +96 -0
- dataface/ai/llm.py +403 -0
- dataface/ai/mcp/__init__.py +51 -0
- dataface/ai/mcp/server.py +289 -0
- dataface/ai/memories.py +85 -0
- dataface/ai/prompts.py +177 -0
- dataface/ai/schema_context.py +138 -0
- dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
- dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
- dataface/ai/skills/dashboard-build/SKILL.md +212 -0
- dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
- dataface/ai/skills/dashboard-design/SKILL.md +182 -0
- dataface/ai/skills/dashboard-review/SKILL.md +113 -0
- dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
- dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
- dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
- dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
- dataface/ai/skills/drill-down-link/SKILL.md +112 -0
- dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
- dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
- dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
- dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
- dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
- dataface/ai/skills/kpi-row/SKILL.md +101 -0
- dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
- dataface/ai/skills/report-design/SKILL.md +184 -0
- dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
- dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
- dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
- dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
- dataface/ai/skills/time-series-trend/SKILL.md +93 -0
- dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
- dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
- dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
- dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
- dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
- dataface/ai/tool_schemas.py +132 -0
- dataface/ai/tools/__init__.py +312 -0
- dataface/ai/yaml_utils.py +57 -0
- dataface/cli/__init__.py +3 -0
- dataface/cli/_console.py +48 -0
- dataface/cli/_error_format.py +83 -0
- dataface/cli/_extras.py +190 -0
- dataface/cli/_json_output.py +8 -0
- dataface/cli/_parsing.py +17 -0
- dataface/cli/_version_info.py +56 -0
- dataface/cli/commands/__init__.py +3 -0
- dataface/cli/commands/_agent_input.py +205 -0
- dataface/cli/commands/_agent_server.py +115 -0
- dataface/cli/commands/chat.py +645 -0
- dataface/cli/commands/describe.py +107 -0
- dataface/cli/commands/docs.py +131 -0
- dataface/cli/commands/extension.py +179 -0
- dataface/cli/commands/init.py +240 -0
- dataface/cli/commands/inspect.py +94 -0
- dataface/cli/commands/mcp_init.py +167 -0
- dataface/cli/commands/query.py +386 -0
- dataface/cli/commands/render.py +291 -0
- dataface/cli/commands/schema.py +411 -0
- dataface/cli/commands/search.py +49 -0
- dataface/cli/commands/serve.py +114 -0
- dataface/cli/commands/skills.py +133 -0
- dataface/cli/commands/skills_init.py +161 -0
- dataface/cli/commands/validate.py +63 -0
- dataface/cli/main.py +1501 -0
- dataface/core/__init__.py +75 -0
- dataface/core/compile/__init__.py +244 -0
- dataface/core/compile/_jinja_helpers.py +78 -0
- dataface/core/compile/channel.py +222 -0
- dataface/core/compile/chart_focus.py +101 -0
- dataface/core/compile/chart_resolved.py +169 -0
- dataface/core/compile/chart_type_detection.py +489 -0
- dataface/core/compile/chart_update.py +261 -0
- dataface/core/compile/colors.py +64 -0
- dataface/core/compile/compiler.py +904 -0
- dataface/core/compile/config.py +823 -0
- dataface/core/compile/custom_chart_types.py +208 -0
- dataface/core/compile/data_table_attachment.py +1287 -0
- dataface/core/compile/detect.py +110 -0
- dataface/core/compile/errors.py +302 -0
- dataface/core/compile/filter_injection.py +319 -0
- dataface/core/compile/introspection.py +527 -0
- dataface/core/compile/jinja.py +511 -0
- dataface/core/compile/labels_env.py +52 -0
- dataface/core/compile/markdown.py +154 -0
- dataface/core/compile/meta.py +388 -0
- dataface/core/compile/models/__init__.py +0 -0
- dataface/core/compile/models/chart/__init__.py +0 -0
- dataface/core/compile/models/chart/authored.py +2137 -0
- dataface/core/compile/models/chart/compiled.py +398 -0
- dataface/core/compile/models/config.py +347 -0
- dataface/core/compile/models/face/__init__.py +0 -0
- dataface/core/compile/models/face/authored.py +659 -0
- dataface/core/compile/models/face/compiled.py +522 -0
- dataface/core/compile/models/factories.py +201 -0
- dataface/core/compile/models/markers.py +40 -0
- dataface/core/compile/models/palette.py +36 -0
- dataface/core/compile/models/primitives.py +415 -0
- dataface/core/compile/models/query/__init__.py +0 -0
- dataface/core/compile/models/query/authored.py +246 -0
- dataface/core/compile/models/query/compiled.py +710 -0
- dataface/core/compile/models/refs.py +137 -0
- dataface/core/compile/models/source.py +611 -0
- dataface/core/compile/models/style/__init__.py +0 -0
- dataface/core/compile/models/style/authored.py +481 -0
- dataface/core/compile/models/style/compiled.py +3399 -0
- dataface/core/compile/models/style/merged.py +1682 -0
- dataface/core/compile/models/theme.py +362 -0
- dataface/core/compile/models/variable/__init__.py +0 -0
- dataface/core/compile/models/variable/authored.py +254 -0
- dataface/core/compile/models/vega_lite/__init__.py +0 -0
- dataface/core/compile/models/vega_lite/config.py +510 -0
- dataface/core/compile/models/vega_lite/contracts.py +171 -0
- dataface/core/compile/normalize_charts.py +494 -0
- dataface/core/compile/normalize_layout.py +1000 -0
- dataface/core/compile/normalize_queries.py +297 -0
- dataface/core/compile/normalize_variables.py +489 -0
- dataface/core/compile/normalizer.py +543 -0
- dataface/core/compile/palette.py +1100 -0
- dataface/core/compile/parameterized.py +658 -0
- dataface/core/compile/parser.py +228 -0
- dataface/core/compile/schema.py +20 -0
- dataface/core/compile/schema_renderers/__init__.py +0 -0
- dataface/core/compile/schema_renderers/json_schema.py +163 -0
- dataface/core/compile/schema_renderers/prompt.py +152 -0
- dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
- dataface/core/compile/sizing.py +2126 -0
- dataface/core/compile/sources.py +518 -0
- dataface/core/compile/sql_authoring_lint.py +56 -0
- dataface/core/compile/style_cascade.py +471 -0
- dataface/core/compile/typography.py +299 -0
- dataface/core/compile/validator.py +301 -0
- dataface/core/compile/variables.py +53 -0
- dataface/core/compile/vega_config.py +98 -0
- dataface/core/compile/vega_lite/__init__.py +6 -0
- dataface/core/compile/vega_lite/validation.py +95 -0
- dataface/core/compile/yaml_error_formatter.py +838 -0
- dataface/core/connections.py +38 -0
- dataface/core/dashboard.py +358 -0
- dataface/core/defaults/default_config.yml +101 -0
- dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
- dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
- dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
- dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
- dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
- dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
- dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
- dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
- dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
- dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
- dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
- dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
- dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
- dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
- dataface/core/defaults/palettes/sequential/amber.yml +22 -0
- dataface/core/defaults/palettes/sequential/blue.yml +22 -0
- dataface/core/defaults/palettes/sequential/brown.yml +22 -0
- dataface/core/defaults/palettes/sequential/gray.yml +22 -0
- dataface/core/defaults/palettes/sequential/green.yml +22 -0
- dataface/core/defaults/palettes/sequential/purple.yml +22 -0
- dataface/core/defaults/palettes/sequential/rust.yml +22 -0
- dataface/core/defaults/palettes/sequential/teal.yml +22 -0
- dataface/core/defaults/palettes/tone/negative.yml +32 -0
- dataface/core/defaults/palettes/tone/positive.yml +22 -0
- dataface/core/defaults/palettes/tone/warning.yml +22 -0
- dataface/core/defaults/themes/_base.yaml +786 -0
- dataface/core/defaults/themes/bi.yaml +16 -0
- dataface/core/defaults/themes/carbong100.yaml +41 -0
- dataface/core/defaults/themes/cream.yaml +122 -0
- dataface/core/defaults/themes/dark.yaml +40 -0
- dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
- dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
- dataface/core/defaults/themes/editorial.yaml +147 -0
- dataface/core/defaults/themes/light.yaml +30 -0
- dataface/core/defaults/themes/looker.yaml +17 -0
- dataface/core/defaults/themes/stark.yaml +134 -0
- dataface/core/errors/__init__.py +67 -0
- dataface/core/errors/codes_compile.py +56 -0
- dataface/core/errors/codes_execute.py +177 -0
- dataface/core/errors/codes_render.py +106 -0
- dataface/core/errors/codes_unknown.py +15 -0
- dataface/core/errors/hints.py +74 -0
- dataface/core/errors/registry.py +42 -0
- dataface/core/errors/structured.py +92 -0
- dataface/core/execute/__init__.py +91 -0
- dataface/core/execute/adapters/__init__.py +49 -0
- dataface/core/execute/adapters/adapter_registry.py +400 -0
- dataface/core/execute/adapters/base.py +245 -0
- dataface/core/execute/adapters/csv_adapter.py +239 -0
- dataface/core/execute/adapters/dbt_adapter.py +283 -0
- dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
- dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
- dataface/core/execute/adapters/dbt_utils.py +150 -0
- dataface/core/execute/adapters/http_adapter.py +224 -0
- dataface/core/execute/adapters/metricflow_adapter.py +94 -0
- dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
- dataface/core/execute/adapters/sql_adapter.py +710 -0
- dataface/core/execute/adapters/values_adapter.py +58 -0
- dataface/core/execute/batch.py +744 -0
- dataface/core/execute/cache_backend.py +135 -0
- dataface/core/execute/cache_keys.py +66 -0
- dataface/core/execute/dbt_jinja.py +21 -0
- dataface/core/execute/dialects/__init__.py +121 -0
- dataface/core/execute/dialects/athena.py +75 -0
- dataface/core/execute/dialects/base.py +302 -0
- dataface/core/execute/dialects/bigquery.py +38 -0
- dataface/core/execute/dialects/databricks.py +68 -0
- dataface/core/execute/dialects/duckdb.py +35 -0
- dataface/core/execute/dialects/mysql.py +68 -0
- dataface/core/execute/dialects/postgres.py +39 -0
- dataface/core/execute/dialects/redshift.py +12 -0
- dataface/core/execute/dialects/snowflake.py +51 -0
- dataface/core/execute/dialects/sqlserver.py +92 -0
- dataface/core/execute/duckdb_cache.py +712 -0
- dataface/core/execute/duckdb_config.py +26 -0
- dataface/core/execute/errors.py +213 -0
- dataface/core/execute/executor.py +1249 -0
- dataface/core/execute/parallel.py +162 -0
- dataface/core/execute/setup_sql.py +58 -0
- dataface/core/execute/source_registry.py +72 -0
- dataface/core/execute/source_resolver.py +255 -0
- dataface/core/execute/sql_guard.py +387 -0
- dataface/core/execute/sql_literals.py +199 -0
- dataface/core/fonts.py +52 -0
- dataface/core/inspect/__init__.py +32 -0
- dataface/core/inspect/cache_factory.py +98 -0
- dataface/core/inspect/db_types.py +162 -0
- dataface/core/inspect/dbt_schema.py +96 -0
- dataface/core/inspect/defaults.yml +37 -0
- dataface/core/inspect/fanout_risk.py +109 -0
- dataface/core/inspect/manifest_utils.py +77 -0
- dataface/core/inspect/partials/categorical.yml +40 -0
- dataface/core/inspect/partials/date.yml +40 -0
- dataface/core/inspect/partials/numeric.yml +55 -0
- dataface/core/inspect/partition_types.py +38 -0
- dataface/core/inspect/query_validator.py +975 -0
- dataface/core/inspect/renderer.py +354 -0
- dataface/core/inspect/resolver.py +808 -0
- dataface/core/inspect/search.py +461 -0
- dataface/core/inspect/sources/__init__.py +32 -0
- dataface/core/inspect/sources/dbt.py +738 -0
- dataface/core/inspect/sources/duckdb_utils.py +66 -0
- dataface/core/inspect/templates/__init__.py +1 -0
- dataface/core/inspect/templates/categorical_column.yml +196 -0
- dataface/core/inspect/templates/charts.yml +109 -0
- dataface/core/inspect/templates/date_column.yml +248 -0
- dataface/core/inspect/templates/model.yml +138 -0
- dataface/core/inspect/templates/numeric_column.yml +261 -0
- dataface/core/inspect/templates/quality.yml +80 -0
- dataface/core/inspect/templates/string_column.yml +263 -0
- dataface/core/project_roots.py +165 -0
- dataface/core/render/__init__.py +87 -0
- dataface/core/render/board_links.py +176 -0
- dataface/core/render/chart/__init__.py +27 -0
- dataface/core/render/chart/arc_attached_table.py +251 -0
- dataface/core/render/chart/artifacts.py +16 -0
- dataface/core/render/chart/callout.py +225 -0
- dataface/core/render/chart/decisions.py +358 -0
- dataface/core/render/chart/geo.py +700 -0
- dataface/core/render/chart/kpi.py +916 -0
- dataface/core/render/chart/labels.py +76 -0
- dataface/core/render/chart/pipeline.py +818 -0
- dataface/core/render/chart/presentation.py +36 -0
- dataface/core/render/chart/profile.py +3438 -0
- dataface/core/render/chart/render_single.py +347 -0
- dataface/core/render/chart/renderers.py +193 -0
- dataface/core/render/chart/rendering.py +565 -0
- dataface/core/render/chart/serialization.py +90 -0
- dataface/core/render/chart/spark.py +496 -0
- dataface/core/render/chart/spark_bar.py +370 -0
- dataface/core/render/chart/spec_builders.py +154 -0
- dataface/core/render/chart/standard_renderer.py +2645 -0
- dataface/core/render/chart/table.py +2957 -0
- dataface/core/render/chart/table_support.py +1452 -0
- dataface/core/render/chart/tick_values.py +66 -0
- dataface/core/render/chart/time_unit_detect.py +809 -0
- dataface/core/render/chart/title_overflow.py +157 -0
- dataface/core/render/chart/type_inference.py +122 -0
- dataface/core/render/chart/validation.py +99 -0
- dataface/core/render/chart/vega_lite.py +125 -0
- dataface/core/render/chart/vega_lite_types.py +268 -0
- dataface/core/render/chart/vl_field_maps.py +346 -0
- dataface/core/render/chart_interactivity.py +24 -0
- dataface/core/render/control_registry.py +287 -0
- dataface/core/render/converters/__init__.py +24 -0
- dataface/core/render/converters/chart.py +276 -0
- dataface/core/render/converters/html.py +98 -0
- dataface/core/render/converters/pdf.py +40 -0
- dataface/core/render/converters/png.py +41 -0
- dataface/core/render/errors.py +144 -0
- dataface/core/render/face_api.py +160 -0
- dataface/core/render/faces.py +1194 -0
- dataface/core/render/font_measurement.py +48 -0
- dataface/core/render/font_support.py +197 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/InterVariable.ttf +0 -0
- dataface/core/render/fonts/InterVariable.woff2 +0 -0
- dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
- dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
- dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
- dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
- dataface/core/render/fonts/_emoji_font_face.css +43 -0
- dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
- dataface/core/render/format_utils.py +329 -0
- dataface/core/render/geo_defaults.yml +28 -0
- dataface/core/render/json_format.py +146 -0
- dataface/core/render/layout_sizing.py +865 -0
- dataface/core/render/layouts.py +541 -0
- dataface/core/render/markdown_defaults.yml +16 -0
- dataface/core/render/missing_vars_prompt.py +79 -0
- dataface/core/render/placeholder.py +389 -0
- dataface/core/render/render_result.py +14 -0
- dataface/core/render/renderer.py +467 -0
- dataface/core/render/script_embedding.py +16 -0
- dataface/core/render/svg_utils.py +212 -0
- dataface/core/render/template_loader.py +69 -0
- dataface/core/render/templates/controls/_styles.css +606 -0
- dataface/core/render/templates/controls/checkbox.html +16 -0
- dataface/core/render/templates/controls/date.html +16 -0
- dataface/core/render/templates/controls/number.html +19 -0
- dataface/core/render/templates/controls/readonly.html +9 -0
- dataface/core/render/templates/controls/select.html +21 -0
- dataface/core/render/templates/controls/slider.html +22 -0
- dataface/core/render/templates/controls/text.html +16 -0
- dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
- dataface/core/render/templates/scripts/variables.js +976 -0
- dataface/core/render/templates/svg/grid_pattern.svg +3 -0
- dataface/core/render/templates/svg/styles.css +51 -0
- dataface/core/render/terminal.py +311 -0
- dataface/core/render/terminal_charts.py +563 -0
- dataface/core/render/terminal_defaults.yml +2 -0
- dataface/core/render/terminal_layouts.py +299 -0
- dataface/core/render/terminal_text.py +31 -0
- dataface/core/render/text/__init__.py +1 -0
- dataface/core/render/text/case.py +113 -0
- dataface/core/render/text_format.py +129 -0
- dataface/core/render/utils.py +106 -0
- dataface/core/render/variable_controls.py +946 -0
- dataface/core/render/variable_input_refinement.py +140 -0
- dataface/core/render/warnings/__init__.py +15 -0
- dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
- dataface/core/render/warnings/base.py +44 -0
- dataface/core/render/warnings/fanout_risk.py +15 -0
- dataface/core/render/warnings/from_query_diagnostic.py +56 -0
- dataface/core/render/warnings/missing_join_predicate.py +13 -0
- dataface/core/render/warnings/query_parse_error.py +14 -0
- dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
- dataface/core/render/warnings/reaggregation.py +14 -0
- dataface/core/render/warnings/registry.py +45 -0
- dataface/core/render/warnings/suppression.py +46 -0
- dataface/core/render/warnings/temporal_single_point.py +63 -0
- dataface/core/render/warnings/unreferenced_chart.py +15 -0
- dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
- dataface/core/render/yaml_format.py +167 -0
- dataface/core/resolve_face.py +195 -0
- dataface/core/schema/__init__.py +0 -0
- dataface/core/schema/guidance.py +151 -0
- dataface/core/scoped_paths.py +59 -0
- dataface/core/serve/__init__.py +14 -0
- dataface/core/serve/bootstrap.py +39 -0
- dataface/core/serve/embedded.py +57 -0
- dataface/core/serve/port.py +129 -0
- dataface/core/serve/server.py +938 -0
- dataface/core/serve/templates/__init__.py +0 -0
- dataface/core/serve/templates/directory.yml +6 -0
- dataface/core/serve/templates/error.html.j2 +217 -0
- dataface/core/utils.py +121 -0
- dataface/core/validate.py +64 -0
- dataface/integrations/__init__.py +0 -0
- dataface/integrations/highlighting.py +351 -0
- dataface/integrations/markdown.py +537 -0
- dataface/py.typed +0 -0
- dataface-0.1.2.dist-info/METADATA +375 -0
- dataface-0.1.2.dist-info/RECORD +455 -0
- dataface-0.1.2.dist-info/WHEEL +4 -0
- dataface-0.1.2.dist-info/entry_points.txt +2 -0
- dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
- mdsvg/__init__.py +168 -0
- mdsvg/fonts.py +656 -0
- mdsvg/images.py +299 -0
- mdsvg/parser.py +629 -0
- mdsvg/playground.py +284 -0
- mdsvg/py.typed +2 -0
- mdsvg/renderer.py +1623 -0
- mdsvg/style.py +355 -0
- mdsvg/types.py +200 -0
- 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
|
+
)
|