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