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,818 @@
|
|
|
1
|
+
"""Chart enrichment and resolution pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import dataclasses
|
|
7
|
+
import math
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from dataface.core.compile.channel import (
|
|
11
|
+
ResolvedStyleChannel,
|
|
12
|
+
normalize_chart_channels,
|
|
13
|
+
parse_style_channel,
|
|
14
|
+
)
|
|
15
|
+
from dataface.core.compile.chart_resolved import (
|
|
16
|
+
ChartLayer,
|
|
17
|
+
ResolvedChart,
|
|
18
|
+
)
|
|
19
|
+
from dataface.core.compile.chart_type_detection import detect_chart_type_full
|
|
20
|
+
from dataface.core.compile.config import get_chart_rendering
|
|
21
|
+
from dataface.core.compile.models.chart.authored import (
|
|
22
|
+
ColumnScaleConfig,
|
|
23
|
+
FieldConditionalFormatting,
|
|
24
|
+
Layer,
|
|
25
|
+
TableColumnConfig,
|
|
26
|
+
)
|
|
27
|
+
from dataface.core.compile.models.chart.compiled import Chart
|
|
28
|
+
from dataface.core.compile.models.style.authored import (
|
|
29
|
+
ChartStylePatch,
|
|
30
|
+
)
|
|
31
|
+
from dataface.core.compile.models.style.compiled import InferenceStyle
|
|
32
|
+
from dataface.core.compile.models.style.merged import (
|
|
33
|
+
MergedChartsStyle,
|
|
34
|
+
MergedStyle,
|
|
35
|
+
)
|
|
36
|
+
from dataface.core.compile.style_cascade import build_resolved_style
|
|
37
|
+
from dataface.core.render.chart.decisions import enrich_chart as enrich_chart_semantics
|
|
38
|
+
from dataface.core.render.chart.type_inference import infer_vega_type_from_data
|
|
39
|
+
from dataface.core.render.font_measurement import get_font_measurer
|
|
40
|
+
|
|
41
|
+
# Fraction of the chart's discrete-x width usable for axis label text. The
|
|
42
|
+
# remainder accounts for the other-axis label band, chart-area inset, and
|
|
43
|
+
# inter-bar gutters. Deliberately crude — catches the common long-vs-short
|
|
44
|
+
# cases cleanly. Used by `_pick_tilt_angle` to decide which tilt increment
|
|
45
|
+
# clears the per-band budget on a vertical bar's x-axis.
|
|
46
|
+
_USABLE_LABEL_AXIS_RATIO = 0.8
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pick_tilt_angle(
|
|
50
|
+
x_field: str | None,
|
|
51
|
+
data: list[dict[str, Any]],
|
|
52
|
+
resolved_charts: MergedChartsStyle,
|
|
53
|
+
chart_width: float,
|
|
54
|
+
) -> tuple[float, bool]:
|
|
55
|
+
"""Walk axis_x.label.tilt_increments and pick the first angle that fits.
|
|
56
|
+
|
|
57
|
+
Returns ``(angle, fits)``. ``fits=False`` means every entry was too dense;
|
|
58
|
+
the caller should downgrade overlap to ``"parity"``.
|
|
59
|
+
"""
|
|
60
|
+
increments = resolved_charts.axis_x.label.tilt_increments
|
|
61
|
+
# min_length=1 in the schema guarantees a non-empty list when the field is set.
|
|
62
|
+
# The fallback here covers the never-resolved sentinel case (e.g. axis_y).
|
|
63
|
+
if not increments:
|
|
64
|
+
return 0.0, True
|
|
65
|
+
if not x_field or not data:
|
|
66
|
+
return 0.0, True
|
|
67
|
+
labels = list({str(row.get(x_field, "")) for row in data[:100]})
|
|
68
|
+
labels = [label for label in labels if label]
|
|
69
|
+
n_labels = len(labels)
|
|
70
|
+
if n_labels == 0:
|
|
71
|
+
return 0.0, True
|
|
72
|
+
font = resolved_charts.axis_x.label.font
|
|
73
|
+
measurer = get_font_measurer(font.family)
|
|
74
|
+
max_width = max(measurer.measure(label, font.size) for label in labels)
|
|
75
|
+
usable_width = chart_width * _USABLE_LABEL_AXIS_RATIO
|
|
76
|
+
band = usable_width / n_labels
|
|
77
|
+
# Line height is per the rendered font; VL paints a single-line axis label.
|
|
78
|
+
line_height = font.size
|
|
79
|
+
for angle in increments:
|
|
80
|
+
radians = math.radians(abs(angle))
|
|
81
|
+
# Horizontal footprint of one label at this tilt: the label width
|
|
82
|
+
# projected onto x by cos(theta), plus the line-height projected by
|
|
83
|
+
# sin(theta) (a tilted label still bleeds half a line-height sideways).
|
|
84
|
+
footprint = max_width * math.cos(radians) + line_height * math.sin(radians)
|
|
85
|
+
if footprint <= band:
|
|
86
|
+
return angle, True
|
|
87
|
+
return increments[-1], False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_discrete_axis(
|
|
91
|
+
x_field: str | None,
|
|
92
|
+
data: list[dict[str, Any]],
|
|
93
|
+
time_unit: str | None = None,
|
|
94
|
+
) -> bool:
|
|
95
|
+
"""True iff the x-axis field renders as discrete categorical bands.
|
|
96
|
+
|
|
97
|
+
Any non-None ``time_unit`` forces continuous — cyclical units (monthofyear,
|
|
98
|
+
dayofweek, weekofyear, hourofday) emit nominal-looking labels but ride a
|
|
99
|
+
temporal scale where parity-drop is the right reduction strategy.
|
|
100
|
+
|
|
101
|
+
Without a time_unit, classification falls back to the data:
|
|
102
|
+
* temporal (ISO dates), quantitative (numbers), ordinal (date-like
|
|
103
|
+
strings like "Q1 2024") → continuous
|
|
104
|
+
* nominal string categories → discrete
|
|
105
|
+
"""
|
|
106
|
+
if time_unit is not None:
|
|
107
|
+
return False
|
|
108
|
+
if not x_field or not data or x_field not in data[0]:
|
|
109
|
+
return False
|
|
110
|
+
return infer_vega_type_from_data(data, x_field) == "nominal"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_overlap_smart_x(
|
|
114
|
+
resolved_charts: MergedChartsStyle,
|
|
115
|
+
x_field: str | None,
|
|
116
|
+
data: list[dict[str, Any]],
|
|
117
|
+
chart_width: float,
|
|
118
|
+
time_unit: str | None = None,
|
|
119
|
+
authored_angle: float | None = None,
|
|
120
|
+
is_horizontal_bar: bool = False,
|
|
121
|
+
) -> MergedChartsStyle:
|
|
122
|
+
"""Resolve overlap='smart' on the x-axis cascade into a concrete value.
|
|
123
|
+
|
|
124
|
+
Dataface ``axis_x`` is always the categorical cascade. On horizontal bars
|
|
125
|
+
bar-flip routes axis_x to VL ``encoding["y"]``; on every other chart shape
|
|
126
|
+
it stays on VL ``encoding["x"]``.
|
|
127
|
+
|
|
128
|
+
Smart overlap contract:
|
|
129
|
+
* horizontal bar, no authored angle → ``angle=0`` + ``overlap='allow'``.
|
|
130
|
+
Horizontal bars carry category labels on VL y where tilt is wrong;
|
|
131
|
+
never parity-drop (every band must keep its label). Renderer grows
|
|
132
|
+
height from category count when the layout slot is too short.
|
|
133
|
+
* continuous x-axis, no authored angle → ``angle=0`` + ``overlap='parity'``.
|
|
134
|
+
Pins VL's adaptive daily-grain tilt and forces reliable parity-drop on
|
|
135
|
+
bucketed temporals.
|
|
136
|
+
* discrete vertical x-axis, no authored angle → walk ``tilt_increments``,
|
|
137
|
+
pick the first angle that fits, emit ``overlap='allow'``. If no angle
|
|
138
|
+
fits, fall through to the last increment + ``overlap='parity'``.
|
|
139
|
+
* authored angle (theme cascade or chart-local) → respect it, emit
|
|
140
|
+
``overlap='allow'``. The author chose a tilt to avoid overlap.
|
|
141
|
+
* non-smart overlap values pass through unchanged.
|
|
142
|
+
|
|
143
|
+
``authored_angle`` carries the chart-local angle if any; the theme-cascaded
|
|
144
|
+
angle lives on ``resolved_charts.axis_x.label.angle``. Either short-circuits
|
|
145
|
+
the picker so user-authored tilts are never overwritten.
|
|
146
|
+
"""
|
|
147
|
+
label = resolved_charts.axis_x.label
|
|
148
|
+
if label.overlap != "smart":
|
|
149
|
+
return resolved_charts
|
|
150
|
+
|
|
151
|
+
effective_angle = authored_angle if authored_angle is not None else label.angle
|
|
152
|
+
|
|
153
|
+
if effective_angle is not None:
|
|
154
|
+
new_label = dataclasses.replace(label, overlap="allow")
|
|
155
|
+
elif is_horizontal_bar:
|
|
156
|
+
new_label = dataclasses.replace(label, overlap="allow", angle=0.0)
|
|
157
|
+
elif not _is_discrete_axis(x_field, data, time_unit=time_unit):
|
|
158
|
+
new_label = dataclasses.replace(label, overlap="parity", angle=0.0)
|
|
159
|
+
else:
|
|
160
|
+
angle, fits = _pick_tilt_angle(x_field, data, resolved_charts, chart_width)
|
|
161
|
+
new_label = dataclasses.replace(
|
|
162
|
+
label,
|
|
163
|
+
overlap="allow" if fits else "parity",
|
|
164
|
+
angle=float(angle),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
new_axis_x = dataclasses.replace(resolved_charts.axis_x, label=new_label)
|
|
168
|
+
return dataclasses.replace(resolved_charts, axis_x=new_axis_x)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Title block + view padding above/below the plot when sizing horizontal bars.
|
|
172
|
+
_HORIZONTAL_BAR_LAYOUT_CHROME_PX = 72.0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def count_horizontal_bar_categories(
|
|
176
|
+
category_field: str | None,
|
|
177
|
+
data: list[dict[str, Any]],
|
|
178
|
+
) -> int:
|
|
179
|
+
"""Distinct category values on the authored ``x`` field (VL y after flip)."""
|
|
180
|
+
if not category_field or not data:
|
|
181
|
+
return 0
|
|
182
|
+
return len(
|
|
183
|
+
{
|
|
184
|
+
str(row[category_field])
|
|
185
|
+
for row in data
|
|
186
|
+
if category_field in row and row[category_field] is not None
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def min_height_for_horizontal_bar_categories(
|
|
192
|
+
n_categories: int,
|
|
193
|
+
resolved_charts: MergedChartsStyle,
|
|
194
|
+
) -> float:
|
|
195
|
+
"""Minimum chart height so every categorical band can show a flat y label."""
|
|
196
|
+
if n_categories <= 0:
|
|
197
|
+
return 0.0
|
|
198
|
+
label = resolved_charts.axis_x.label
|
|
199
|
+
bar_mark = resolved_charts.marks.bar
|
|
200
|
+
font_size = label.font.size
|
|
201
|
+
separation = label.separation
|
|
202
|
+
padding = label.padding
|
|
203
|
+
bar_size = bar_mark.size
|
|
204
|
+
assert (
|
|
205
|
+
font_size is not None
|
|
206
|
+
), "axis_x.label.font.size unset — theme cascade must populate it"
|
|
207
|
+
assert (
|
|
208
|
+
separation is not None
|
|
209
|
+
), "axis_x.label.separation unset — theme cascade must populate it"
|
|
210
|
+
assert (
|
|
211
|
+
padding is not None
|
|
212
|
+
), "axis_x.label.padding unset — theme cascade must populate it"
|
|
213
|
+
assert bar_size is not None, "marks.bar.size unset — theme cascade must populate it"
|
|
214
|
+
label_line = font_size + 2 * padding
|
|
215
|
+
band_step = max(bar_size, label_line) + separation
|
|
216
|
+
return _HORIZONTAL_BAR_LAYOUT_CHROME_PX + n_categories * band_step
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _family_patch_for(style: ChartStylePatch | Any, chart_type: str) -> Any:
|
|
220
|
+
"""Return the family sub-patch for chart_type, or None.
|
|
221
|
+
|
|
222
|
+
Uses the same chart-type → family-key mapping as _normalize_chart so
|
|
223
|
+
pipeline reads are consistent with how the patch was written.
|
|
224
|
+
"""
|
|
225
|
+
from dataface.core.compile.normalize_charts import (
|
|
226
|
+
_AUTHORED_FAMILY_TO_MONOLITHIC_KEY,
|
|
227
|
+
)
|
|
228
|
+
from dataface.core.compile.style_cascade import _get_primary_patch
|
|
229
|
+
|
|
230
|
+
if not isinstance(style, ChartStylePatch):
|
|
231
|
+
return None
|
|
232
|
+
key = _AUTHORED_FAMILY_TO_MONOLITHIC_KEY.get(chart_type)
|
|
233
|
+
if key is not None:
|
|
234
|
+
return getattr(style, key, None)
|
|
235
|
+
# For types not in the mapping (layered, auto, …) fall back to the first
|
|
236
|
+
# non-null family patch.
|
|
237
|
+
return _get_primary_patch(style)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _read_authored_format(chart: Chart | Any) -> Any:
|
|
241
|
+
"""Extract authored y-axis format from chart.format or style.axis_y.format."""
|
|
242
|
+
if chart.format:
|
|
243
|
+
return chart.format
|
|
244
|
+
style = getattr(chart, "style", None)
|
|
245
|
+
patch = _family_patch_for(style, getattr(chart, "type", ""))
|
|
246
|
+
axis_y = getattr(patch, "axis_y", None) if patch is not None else None
|
|
247
|
+
return axis_y.format if axis_y is not None else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _read_authored_zero(chart: Chart | Any) -> Any:
|
|
251
|
+
"""Extract authored scale zero from style.scale.zero."""
|
|
252
|
+
style = getattr(chart, "style", None)
|
|
253
|
+
patch = _family_patch_for(style, getattr(chart, "type", ""))
|
|
254
|
+
scale = getattr(patch, "scale", None) if patch is not None else None
|
|
255
|
+
return scale.zero if scale is not None else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _read_authored_stack(chart: Chart | Any) -> Any:
|
|
259
|
+
"""Cascade stack: top-level chart.stack wins; fall back to style.stack."""
|
|
260
|
+
top = getattr(chart, "stack", None)
|
|
261
|
+
if top is not None:
|
|
262
|
+
return top
|
|
263
|
+
style = getattr(chart, "style", None)
|
|
264
|
+
patch = _family_patch_for(style, getattr(chart, "type", ""))
|
|
265
|
+
return getattr(patch, "stack", None) if patch is not None else None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _convert_layers(
|
|
269
|
+
raw_layers: list[Layer] | None,
|
|
270
|
+
) -> tuple[ChartLayer, ...]:
|
|
271
|
+
"""Convert authored Layer objects to frozen ChartLayer tuples.
|
|
272
|
+
|
|
273
|
+
By the time this runs, Pydantic has already coerced dicts to Layer
|
|
274
|
+
(Chart.layers is ``list[Layer] | None``).
|
|
275
|
+
"""
|
|
276
|
+
if not raw_layers:
|
|
277
|
+
return ()
|
|
278
|
+
return tuple(
|
|
279
|
+
ChartLayer(
|
|
280
|
+
chart_type=layer.type,
|
|
281
|
+
query_name=layer.query,
|
|
282
|
+
x=layer.x,
|
|
283
|
+
y=layer.y,
|
|
284
|
+
label=layer.label,
|
|
285
|
+
color=layer.color,
|
|
286
|
+
fill=layer.fill,
|
|
287
|
+
size=layer.size,
|
|
288
|
+
shape=layer.shape,
|
|
289
|
+
axis_y=(
|
|
290
|
+
layer.axis_y.model_dump(exclude_none=True)
|
|
291
|
+
if layer.axis_y is not None
|
|
292
|
+
else None
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
for layer in raw_layers
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _authored_color_field(chart: Chart | Any) -> str | None:
|
|
300
|
+
"""Return the authored color data field using the canonical parser."""
|
|
301
|
+
color = getattr(chart, "color", None)
|
|
302
|
+
if color is None:
|
|
303
|
+
return None
|
|
304
|
+
ch = parse_style_channel(color, "color")
|
|
305
|
+
return ch.data_field or None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _chart_for_decisions(
|
|
309
|
+
chart: Chart | Any,
|
|
310
|
+
resolved_type: str,
|
|
311
|
+
) -> Chart | Any:
|
|
312
|
+
"""Return chart with resolved type for the decisions enrichment helper."""
|
|
313
|
+
if resolved_type == getattr(chart, "type", None):
|
|
314
|
+
return chart
|
|
315
|
+
if isinstance(chart, Chart):
|
|
316
|
+
return chart.model_copy(update={"type": resolved_type})
|
|
317
|
+
chart_copy = copy.copy(chart)
|
|
318
|
+
chart_copy.type = resolved_type
|
|
319
|
+
return chart_copy
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def compute_enrichments(
|
|
323
|
+
chart: Chart | Any,
|
|
324
|
+
data: list[dict[str, Any]],
|
|
325
|
+
column_descriptions: dict[str, tuple] | None = None,
|
|
326
|
+
*,
|
|
327
|
+
inference: InferenceStyle,
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
"""Compute data-driven field inferences. Returns sparse dict of inferred values only."""
|
|
330
|
+
if not data:
|
|
331
|
+
return {}
|
|
332
|
+
|
|
333
|
+
authored_zero = _read_authored_zero(chart)
|
|
334
|
+
infer_zero = inference.infer_zero_when_missing and authored_zero is None
|
|
335
|
+
|
|
336
|
+
chart_type = getattr(chart, "type", None) or "auto"
|
|
337
|
+
needs_type = chart_type == "auto"
|
|
338
|
+
needs_fields = (
|
|
339
|
+
any(getattr(chart, f, None) is None for f in ("x", "y", "theta"))
|
|
340
|
+
or getattr(chart, "color", None) is None
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if not (needs_type or needs_fields or infer_zero):
|
|
344
|
+
return {}
|
|
345
|
+
|
|
346
|
+
inferred: dict[str, Any] = {}
|
|
347
|
+
has_authored_color = getattr(chart, "color", None) is not None
|
|
348
|
+
authored_color_field = _authored_color_field(chart)
|
|
349
|
+
value = getattr(chart, "value", None)
|
|
350
|
+
value_for_inference = value if isinstance(value, str) else None
|
|
351
|
+
|
|
352
|
+
if inference.infer_type_when_auto and needs_type:
|
|
353
|
+
y = getattr(chart, "y", None)
|
|
354
|
+
result = detect_chart_type_full(
|
|
355
|
+
data,
|
|
356
|
+
x_field=getattr(chart, "x", None),
|
|
357
|
+
y_field=(y[0] if isinstance(y, list) else y),
|
|
358
|
+
color_field=authored_color_field,
|
|
359
|
+
value_field=value_for_inference,
|
|
360
|
+
column_descriptions=column_descriptions,
|
|
361
|
+
)
|
|
362
|
+
inferred["chart_type"] = result.chart_type
|
|
363
|
+
if value_for_inference is None and result.value:
|
|
364
|
+
inferred["value"] = result.value
|
|
365
|
+
if getattr(chart, "theta", None) is None and result.theta:
|
|
366
|
+
inferred["theta"] = result.theta
|
|
367
|
+
if getattr(chart, "x", None) is None and result.x:
|
|
368
|
+
inferred["x"] = result.x
|
|
369
|
+
if getattr(chart, "y", None) is None and result.y:
|
|
370
|
+
inferred["y"] = result.y
|
|
371
|
+
if not has_authored_color and result.color:
|
|
372
|
+
inferred["color"] = result.color
|
|
373
|
+
|
|
374
|
+
resolved_type = inferred.get("chart_type", chart_type)
|
|
375
|
+
enriched = enrich_chart_semantics(
|
|
376
|
+
_chart_for_decisions(chart, resolved_type), data, column_descriptions
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if inference.infer_fields_when_missing:
|
|
380
|
+
if getattr(chart, "x", None) is None and getattr(enriched, "x", None):
|
|
381
|
+
inferred.setdefault("x", enriched.x)
|
|
382
|
+
if getattr(chart, "y", None) is None and getattr(enriched, "y", None):
|
|
383
|
+
inferred.setdefault("y", enriched.y)
|
|
384
|
+
if not has_authored_color and getattr(enriched, "color", None):
|
|
385
|
+
inferred.setdefault("color", enriched.color)
|
|
386
|
+
enriched_value = getattr(enriched, "value", None)
|
|
387
|
+
if value_for_inference is None and isinstance(enriched_value, str):
|
|
388
|
+
inferred.setdefault("value", enriched_value)
|
|
389
|
+
if getattr(chart, "theta", None) is None and getattr(enriched, "theta", None):
|
|
390
|
+
inferred.setdefault("theta", enriched.theta)
|
|
391
|
+
|
|
392
|
+
if infer_zero:
|
|
393
|
+
enriched_style = getattr(enriched, "style", None)
|
|
394
|
+
if isinstance(enriched_style, ChartStylePatch):
|
|
395
|
+
enriched_patch = _family_patch_for(enriched_style, chart_type)
|
|
396
|
+
_ep_scale = (
|
|
397
|
+
getattr(enriched_patch, "scale", None)
|
|
398
|
+
if enriched_patch is not None
|
|
399
|
+
else None
|
|
400
|
+
)
|
|
401
|
+
if _ep_scale is not None and _ep_scale.zero is not None:
|
|
402
|
+
inferred["zero"] = _ep_scale.zero
|
|
403
|
+
|
|
404
|
+
return inferred
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
_TABLE_COLOR_CHANNELS = frozenset({"color", "background"})
|
|
408
|
+
_TABLE_LOWERABLE_MODES = frozenset({"gradient"})
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _build_col_map(
|
|
412
|
+
existing_cols: dict[str, Any],
|
|
413
|
+
lowerable: dict[str, Any],
|
|
414
|
+
) -> dict[str, TableColumnConfig]:
|
|
415
|
+
"""Build column→TableColumnConfig map from existing columns, updated with
|
|
416
|
+
per-channel gradients.
|
|
417
|
+
"""
|
|
418
|
+
col_map: dict[str, TableColumnConfig] = {}
|
|
419
|
+
for field_name, col_cfg in existing_cols.items():
|
|
420
|
+
if isinstance(col_cfg, TableColumnConfig):
|
|
421
|
+
col_map[field_name] = col_cfg
|
|
422
|
+
elif isinstance(col_cfg, dict):
|
|
423
|
+
col_map[field_name] = TableColumnConfig.model_validate(col_cfg)
|
|
424
|
+
|
|
425
|
+
# Lower per-channel gradients (color.scale, background.scale) onto the
|
|
426
|
+
# target column's ColumnScaleConfig.
|
|
427
|
+
for ch_name, ch in lowerable.items():
|
|
428
|
+
field_name = ch.data_field
|
|
429
|
+
existing_cfg = col_map.get(field_name) or TableColumnConfig()
|
|
430
|
+
existing_scale = existing_cfg.scale or ColumnScaleConfig()
|
|
431
|
+
new_scale = existing_scale.model_copy(update={ch_name: ch.scale})
|
|
432
|
+
col_map[field_name] = existing_cfg.model_copy(update={"scale": new_scale})
|
|
433
|
+
|
|
434
|
+
return col_map
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _apply_col_map(
|
|
438
|
+
existing_cols: dict[str, Any],
|
|
439
|
+
col_map: dict[str, TableColumnConfig],
|
|
440
|
+
) -> dict[str, TableColumnConfig]:
|
|
441
|
+
"""Merge col_map back into the columns dict, preserving authoring order and appending new columns."""
|
|
442
|
+
result: dict[str, TableColumnConfig] = {}
|
|
443
|
+
for field_name, col_cfg in existing_cols.items():
|
|
444
|
+
if field_name in col_map:
|
|
445
|
+
result[field_name] = col_map[field_name]
|
|
446
|
+
elif isinstance(col_cfg, TableColumnConfig):
|
|
447
|
+
result[field_name] = col_cfg
|
|
448
|
+
elif isinstance(col_cfg, dict):
|
|
449
|
+
result[field_name] = TableColumnConfig.model_validate(col_cfg)
|
|
450
|
+
else:
|
|
451
|
+
result[field_name] = TableColumnConfig()
|
|
452
|
+
for field_name, cfg in col_map.items():
|
|
453
|
+
if field_name not in result:
|
|
454
|
+
result[field_name] = cfg
|
|
455
|
+
return result
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _lower_channels_to_table(
|
|
459
|
+
resolved: ResolvedChart,
|
|
460
|
+
resolved_channels: dict[str, Any],
|
|
461
|
+
) -> tuple[ResolvedChart, dict[str, Any]]:
|
|
462
|
+
"""Lower chart-level gradient channels onto ``style.columns[*].scale``.
|
|
463
|
+
|
|
464
|
+
Channels in gradient mode lower onto ``TableColumnConfig.scale`` so the
|
|
465
|
+
table renderer can interpolate a per-cell color from the column value.
|
|
466
|
+
Threshold rules (``conditional_formatting``) are read directly by the
|
|
467
|
+
table renderer from the chart-level block — no lowering required.
|
|
468
|
+
"""
|
|
469
|
+
unsupported = [ch for ch in resolved_channels if ch not in _TABLE_COLOR_CHANNELS]
|
|
470
|
+
if unsupported:
|
|
471
|
+
raise ValueError(
|
|
472
|
+
f"Table charts do not support channel(s): {sorted(unsupported)}. "
|
|
473
|
+
"Meaningful table channels: color, background."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
lowerable: dict[str, Any] = {}
|
|
477
|
+
for ch_name, ch in resolved_channels.items():
|
|
478
|
+
if ch_name not in _TABLE_COLOR_CHANNELS:
|
|
479
|
+
continue
|
|
480
|
+
if ch.mode not in _TABLE_LOWERABLE_MODES:
|
|
481
|
+
raise ValueError(
|
|
482
|
+
f"Table chart does not support chart.{ch_name} in '{ch.mode}' mode. "
|
|
483
|
+
f"Tables render one cell per row — use "
|
|
484
|
+
f"chart.conditional_formatting.<column>.when for threshold coloring or "
|
|
485
|
+
f"chart.{ch_name}: {{column: X, scale: {{palette: [...]}}}} "
|
|
486
|
+
"for gradient coloring."
|
|
487
|
+
)
|
|
488
|
+
lowerable[ch_name] = ch
|
|
489
|
+
|
|
490
|
+
if not lowerable:
|
|
491
|
+
return resolved, resolved_channels
|
|
492
|
+
|
|
493
|
+
existing_cols: dict[str, Any] = dict(resolved.columns or {})
|
|
494
|
+
col_map = _build_col_map(existing_cols, lowerable)
|
|
495
|
+
new_columns = _apply_col_map(existing_cols, col_map)
|
|
496
|
+
remaining = {k: v for k, v in resolved_channels.items() if k not in lowerable}
|
|
497
|
+
return dataclasses.replace(resolved, columns=new_columns), remaining
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# Chart types where the `color` channel paints the mark (so rule.background
|
|
501
|
+
# lowers into a mark-fill VL condition). Other VL types (arc/pie/rect/etc.)
|
|
502
|
+
# also use color for the mark fill; this set is the intersection that the
|
|
503
|
+
# current migrator + tests exercise.
|
|
504
|
+
_MARK_FILL_CHART_TYPES: frozenset[str] = frozenset({"bar", "line", "area", "scatter"})
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _project_conditional_formatting(
|
|
508
|
+
chart: Any,
|
|
509
|
+
available_columns: set[str],
|
|
510
|
+
) -> dict[str, ResolvedStyleChannel]:
|
|
511
|
+
"""Project ``chart.conditional_formatting`` into per-channel conditional channels.
|
|
512
|
+
|
|
513
|
+
Projection rules:
|
|
514
|
+
* bar/line/area/scatter: rules with ``background`` lower into the
|
|
515
|
+
``color`` channel (mark fill) as a VL condition list. Font-only rules
|
|
516
|
+
are ignored — marks have no text.
|
|
517
|
+
* kpi: rules with ``background`` lower into the ``background`` channel
|
|
518
|
+
(card fill). Rules with ``font.color`` lower into the ``color``
|
|
519
|
+
channel (number text).
|
|
520
|
+
|
|
521
|
+
Multi-column support is deferred; tests today exercise one metric column
|
|
522
|
+
per chart. Raises ValueError on unknown columns or multi-column usage.
|
|
523
|
+
"""
|
|
524
|
+
cf: dict[str, FieldConditionalFormatting] | None = getattr(
|
|
525
|
+
chart, "conditional_formatting", None
|
|
526
|
+
)
|
|
527
|
+
if not cf:
|
|
528
|
+
return {}
|
|
529
|
+
|
|
530
|
+
chart_type = getattr(chart, "type", None)
|
|
531
|
+
for column_name in cf:
|
|
532
|
+
if available_columns and column_name not in available_columns:
|
|
533
|
+
raise ValueError(
|
|
534
|
+
f"conditional_formatting targets column '{column_name}' which is "
|
|
535
|
+
f"not in the query result. Available: {sorted(available_columns)}"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if chart_type in _MARK_FILL_CHART_TYPES:
|
|
539
|
+
outputs = {"color": lambda r: r.background}
|
|
540
|
+
elif chart_type == "kpi":
|
|
541
|
+
outputs = {
|
|
542
|
+
"background": lambda r: r.background,
|
|
543
|
+
"color": lambda r: r.font.color if r.font is not None else None,
|
|
544
|
+
}
|
|
545
|
+
else:
|
|
546
|
+
return {}
|
|
547
|
+
|
|
548
|
+
projected: dict[str, ResolvedStyleChannel] = {}
|
|
549
|
+
for channel_name, extractor in outputs.items():
|
|
550
|
+
per_column = [
|
|
551
|
+
(column, tuple(r for r in entry.when if extractor(r) is not None))
|
|
552
|
+
for column, entry in cf.items()
|
|
553
|
+
]
|
|
554
|
+
per_column = [(c, rs) for c, rs in per_column if rs]
|
|
555
|
+
if not per_column:
|
|
556
|
+
continue
|
|
557
|
+
if len(per_column) > 1:
|
|
558
|
+
raise ValueError(
|
|
559
|
+
f"conditional_formatting on channel '{channel_name}' supports "
|
|
560
|
+
"one column's rules per chart in v1 — got rules for: "
|
|
561
|
+
f"{sorted(c for c, _ in per_column)}."
|
|
562
|
+
)
|
|
563
|
+
column, rules = per_column[0]
|
|
564
|
+
projected[channel_name] = ResolvedStyleChannel(
|
|
565
|
+
channel=channel_name,
|
|
566
|
+
mode="conditional",
|
|
567
|
+
data_field=column,
|
|
568
|
+
rules=rules,
|
|
569
|
+
)
|
|
570
|
+
return projected
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _available_columns_for_validation(
|
|
574
|
+
chart: Chart | Any,
|
|
575
|
+
data: list[dict[str, Any]],
|
|
576
|
+
) -> set[str]:
|
|
577
|
+
"""Return columns visible to chart-level validation.
|
|
578
|
+
|
|
579
|
+
Query-level pivot keeps SQL long-form but table rendering sees generated
|
|
580
|
+
wide columns named from the pivot column values. Validation must recognize
|
|
581
|
+
those generated columns or table conditional formatting cannot target them.
|
|
582
|
+
"""
|
|
583
|
+
if not data:
|
|
584
|
+
return set()
|
|
585
|
+
|
|
586
|
+
columns = set(data[0].keys())
|
|
587
|
+
chart_pivot = getattr(getattr(chart, "query", None), "pivot", None)
|
|
588
|
+
if (
|
|
589
|
+
getattr(chart, "type", None) == "table"
|
|
590
|
+
and chart_pivot is not None
|
|
591
|
+
and chart_pivot.column in columns
|
|
592
|
+
and chart_pivot.value in columns
|
|
593
|
+
):
|
|
594
|
+
pivot_columns = {str(row[chart_pivot.column]) for row in data}
|
|
595
|
+
return (columns - {chart_pivot.column, chart_pivot.value}) | pivot_columns
|
|
596
|
+
|
|
597
|
+
return columns
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def resolve_chart(
|
|
601
|
+
chart: Chart | Any,
|
|
602
|
+
data: list[dict[str, Any]],
|
|
603
|
+
column_descriptions: dict[str, tuple] | None = None,
|
|
604
|
+
*,
|
|
605
|
+
# Intentionally optional: resolve_chart serves both visual rendering (where
|
|
606
|
+
# callers thread the face's MergedStyle) and non-visual resolution (type
|
|
607
|
+
# inference, axis analysis, label routing) where no board style is needed.
|
|
608
|
+
# Renderer callers that require visual fidelity must pass board_style;
|
|
609
|
+
# SvgRenderer and the geo branches of render_standard_vega_spec raise if
|
|
610
|
+
# resolved_style is absent rather than synthesising a silent default.
|
|
611
|
+
board_style: MergedStyle | None = None,
|
|
612
|
+
) -> ResolvedChart:
|
|
613
|
+
"""Resolve a chart into render-ready semantics."""
|
|
614
|
+
chart_style = getattr(chart, "style", None)
|
|
615
|
+
_authored_type = getattr(chart, "type", None) or "auto"
|
|
616
|
+
# Pass the authored chart type so build_resolved_style can apply per-family
|
|
617
|
+
# theme patches (e.g. editorial bar.legend.disable: false). Skip when type is
|
|
618
|
+
# "auto" — the concrete type isn't known until after compute_enrichments.
|
|
619
|
+
_build_chart_type = _authored_type if _authored_type != "auto" else None
|
|
620
|
+
resolved_style = build_resolved_style(
|
|
621
|
+
board_style, chart_style, chart_type=_build_chart_type
|
|
622
|
+
)
|
|
623
|
+
inference = resolved_style.inference
|
|
624
|
+
|
|
625
|
+
authored_format = _read_authored_format(chart)
|
|
626
|
+
authored_zero = _read_authored_zero(chart)
|
|
627
|
+
inferred = compute_enrichments(
|
|
628
|
+
chart, data, column_descriptions, inference=inference
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
chart_type = _authored_type
|
|
632
|
+
resolved_type = (
|
|
633
|
+
inferred.get("chart_type", chart_type) if chart_type == "auto" else chart_type
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# When ``type: auto`` resolves to ``kpi`` at render time, the normalizer
|
|
637
|
+
# routed the chart-id slug into ``title`` (because chart_type was ``auto``
|
|
638
|
+
# at normalize time, not ``kpi``). Carry that auto-derived text into
|
|
639
|
+
# ``label`` so the KPI renderer reads the intended display string instead
|
|
640
|
+
# of falling through to the value-column name.
|
|
641
|
+
chart_label = getattr(chart, "label", "") or ""
|
|
642
|
+
chart_title = getattr(chart, "title", "") or ""
|
|
643
|
+
if resolved_type == "kpi" and not chart_label and chart_title:
|
|
644
|
+
resolved_label = chart_title
|
|
645
|
+
resolved_title = ""
|
|
646
|
+
else:
|
|
647
|
+
resolved_label = chart_label
|
|
648
|
+
resolved_title = chart_title
|
|
649
|
+
|
|
650
|
+
# Active family sub-patch for shared-field reads (axis_x, orientation, etc.)
|
|
651
|
+
family_patch = _family_patch_for(chart_style, chart_type)
|
|
652
|
+
|
|
653
|
+
x_field_for_overlap = getattr(chart, "x", None) or inferred.get("x")
|
|
654
|
+
chart_width_for_overlap = (
|
|
655
|
+
getattr(chart, "width", None) or get_chart_rendering().default_width
|
|
656
|
+
)
|
|
657
|
+
_fp_axis_x = (
|
|
658
|
+
getattr(family_patch, "axis_x", None) if family_patch is not None else None
|
|
659
|
+
)
|
|
660
|
+
chart_time_unit = _fp_axis_x.time_unit if _fp_axis_x is not None else None
|
|
661
|
+
|
|
662
|
+
# Bar orientation is type-driven. Discrete x (nominal categories)
|
|
663
|
+
# reads naturally horizontal; continuous x (temporal, quantitative,
|
|
664
|
+
# date-like ordinal, time_unit-bucketed) reads naturally vertical. Same
|
|
665
|
+
# `_is_discrete_axis` helper that drives the smart-tilt resolver.
|
|
666
|
+
authored_orientation = (
|
|
667
|
+
getattr(family_patch, "orientation", None) if family_patch is not None else None
|
|
668
|
+
)
|
|
669
|
+
if authored_orientation in ("horizontal", "vertical"):
|
|
670
|
+
orientation: Literal["horizontal", "vertical"] = authored_orientation
|
|
671
|
+
elif resolved_type == "bar" and _is_discrete_axis(
|
|
672
|
+
x_field_for_overlap, data, time_unit=chart_time_unit
|
|
673
|
+
):
|
|
674
|
+
orientation = "horizontal"
|
|
675
|
+
else:
|
|
676
|
+
orientation = "vertical"
|
|
677
|
+
# Chart-local style.axis_x.label.angle lives on the chart Patch, not on the
|
|
678
|
+
# theme-cascaded resolved_style.axis_x — build_resolved_style routes it to
|
|
679
|
+
# axis_overrides_x for emit-time merging. Surface it here so the dispatcher
|
|
680
|
+
# can short-circuit the picker when an author specified a tilt directly.
|
|
681
|
+
_fp_axis_x_label = (
|
|
682
|
+
getattr(_fp_axis_x, "label", None) if _fp_axis_x is not None else None
|
|
683
|
+
)
|
|
684
|
+
chart_authored_angle = (
|
|
685
|
+
_fp_axis_x_label.angle if _fp_axis_x_label is not None else None
|
|
686
|
+
)
|
|
687
|
+
resolved_style = _resolve_overlap_smart_x(
|
|
688
|
+
resolved_style,
|
|
689
|
+
x_field_for_overlap,
|
|
690
|
+
data,
|
|
691
|
+
chart_width_for_overlap,
|
|
692
|
+
time_unit=chart_time_unit,
|
|
693
|
+
authored_angle=chart_authored_angle,
|
|
694
|
+
is_horizontal_bar=(resolved_type == "bar" and orientation == "horizontal"),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
# Table-specific fields live on the table family sub-patch.
|
|
698
|
+
table_patch = (
|
|
699
|
+
getattr(chart_style, "table", None) if chart_style is not None else None
|
|
700
|
+
)
|
|
701
|
+
style_columns = table_patch.columns if table_patch is not None else None
|
|
702
|
+
header_overflow = table_patch.header_overflow if table_patch is not None else None
|
|
703
|
+
column_defaults = table_patch.column_defaults if table_patch is not None else None
|
|
704
|
+
|
|
705
|
+
resolved = ResolvedChart(
|
|
706
|
+
source_chart=chart,
|
|
707
|
+
id=getattr(chart, "id", "unknown"),
|
|
708
|
+
chart_type=resolved_type,
|
|
709
|
+
title=resolved_title,
|
|
710
|
+
subtitle=getattr(chart, "subtitle", "") or "",
|
|
711
|
+
description=getattr(chart, "description", "") or "",
|
|
712
|
+
label=resolved_label,
|
|
713
|
+
x=(
|
|
714
|
+
getattr(chart, "x", None)
|
|
715
|
+
if getattr(chart, "x", None) is not None
|
|
716
|
+
else inferred.get("x")
|
|
717
|
+
),
|
|
718
|
+
y=(
|
|
719
|
+
getattr(chart, "y", None)
|
|
720
|
+
if getattr(chart, "y", None) is not None
|
|
721
|
+
else inferred.get("y")
|
|
722
|
+
),
|
|
723
|
+
size=getattr(chart, "size", None),
|
|
724
|
+
shape=getattr(chart, "shape", None),
|
|
725
|
+
theta=(
|
|
726
|
+
getattr(chart, "theta", None)
|
|
727
|
+
if getattr(chart, "theta", None) is not None
|
|
728
|
+
else inferred.get("theta")
|
|
729
|
+
),
|
|
730
|
+
total=getattr(chart, "total", None),
|
|
731
|
+
labels=getattr(chart, "labels", None),
|
|
732
|
+
format=authored_format,
|
|
733
|
+
message=getattr(chart, "message", None),
|
|
734
|
+
zero=authored_zero if authored_zero is not None else inferred.get("zero"),
|
|
735
|
+
x_label=getattr(chart, "x_label", None),
|
|
736
|
+
y_label=getattr(chart, "y_label", None),
|
|
737
|
+
geo=getattr(chart, "geo", None),
|
|
738
|
+
geo_source=getattr(chart, "geo_source", None),
|
|
739
|
+
lookup=getattr(chart, "lookup", None),
|
|
740
|
+
value=(
|
|
741
|
+
getattr(chart, "value", None)
|
|
742
|
+
if getattr(chart, "value", None) is not None
|
|
743
|
+
else inferred.get("value")
|
|
744
|
+
),
|
|
745
|
+
support=getattr(chart, "support", None),
|
|
746
|
+
projection=getattr(chart, "projection", None),
|
|
747
|
+
latitude=getattr(chart, "latitude", None),
|
|
748
|
+
longitude=getattr(chart, "longitude", None),
|
|
749
|
+
basemap=getattr(chart, "basemap", None),
|
|
750
|
+
sort=getattr(chart, "sort", None),
|
|
751
|
+
stack=_read_authored_stack(chart),
|
|
752
|
+
orientation=orientation,
|
|
753
|
+
link=getattr(chart, "link", None),
|
|
754
|
+
query_name=getattr(chart, "query_name", None),
|
|
755
|
+
variable_dependencies=set(getattr(chart, "variable_dependencies", set())),
|
|
756
|
+
resolved_style=resolved_style,
|
|
757
|
+
columns=style_columns,
|
|
758
|
+
header_overflow=header_overflow,
|
|
759
|
+
column_defaults=column_defaults,
|
|
760
|
+
layers=_convert_layers(getattr(chart, "layers", None)),
|
|
761
|
+
x_domain=getattr(chart, "x_domain", None),
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
available_cols = _available_columns_for_validation(chart, data)
|
|
765
|
+
style_color = (
|
|
766
|
+
getattr(family_patch, "color", None) if family_patch is not None else None
|
|
767
|
+
)
|
|
768
|
+
resolved_channels = normalize_chart_channels(
|
|
769
|
+
chart, available_cols, style_color=style_color
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# Project the chart-level conditional_formatting block into synthetic
|
|
773
|
+
# per-channel conditional channels for VL mark-fill chart types and KPI.
|
|
774
|
+
# Tables read the block directly at render time; projection is a no-op
|
|
775
|
+
# there but the unknown-column validation still runs.
|
|
776
|
+
projected = _project_conditional_formatting(chart, available_cols)
|
|
777
|
+
for channel_name, projected_ch in projected.items():
|
|
778
|
+
if channel_name in resolved_channels:
|
|
779
|
+
existing = resolved_channels[channel_name]
|
|
780
|
+
# Gradient + conditional on the same channel: compose them so
|
|
781
|
+
# threshold rules take priority and the gradient shows through when
|
|
782
|
+
# no rule matches. This mirrors Looker's "scale with rule override"
|
|
783
|
+
# behaviour and is the only safe composition — all other conflicts
|
|
784
|
+
# (e.g. two gradients, literal + conditional) remain an error.
|
|
785
|
+
if existing.mode == "gradient" and projected_ch.mode == "conditional":
|
|
786
|
+
projected_ch = dataclasses.replace(
|
|
787
|
+
projected_ch, fallback_scale=existing.scale
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
raise ValueError(
|
|
791
|
+
f"chart.{channel_name} conflicts with conditional_formatting "
|
|
792
|
+
f"rules on column '{projected_ch.data_field}'. Use one surface: "
|
|
793
|
+
"per-channel encoding (scale/value/column) OR rule-driven "
|
|
794
|
+
"conditional_formatting."
|
|
795
|
+
)
|
|
796
|
+
resolved_channels[channel_name] = projected_ch
|
|
797
|
+
|
|
798
|
+
# Add enrichment-inferred color channel when the user did not author one.
|
|
799
|
+
# Must happen before table lowering so the injected channel passes through
|
|
800
|
+
# the same lowering gate as authored channels.
|
|
801
|
+
inferred_color = inferred.get("color")
|
|
802
|
+
if inferred_color and "color" not in resolved_channels:
|
|
803
|
+
resolved_channels = {
|
|
804
|
+
**resolved_channels,
|
|
805
|
+
"color": ResolvedStyleChannel(
|
|
806
|
+
channel="color", mode="series", data_field=str(inferred_color)
|
|
807
|
+
),
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if resolved.chart_type == "table" and resolved_channels:
|
|
811
|
+
resolved, resolved_channels = _lower_channels_to_table(
|
|
812
|
+
resolved, resolved_channels
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
return dataclasses.replace(
|
|
816
|
+
resolved,
|
|
817
|
+
resolved_channels=resolved_channels,
|
|
818
|
+
)
|