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