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,1682 @@
|
|
|
1
|
+
"""Style cascade-merge models — frozen dataclasses and merge logic.
|
|
2
|
+
|
|
3
|
+
MergedXxx are frozen dataclasses (post-cascade, all fields required).
|
|
4
|
+
resolve_style() merges a compiled Style with optional *Patch overlays and
|
|
5
|
+
returns a MergedStyle for consumption by render/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import dataclasses
|
|
12
|
+
import re
|
|
13
|
+
import types
|
|
14
|
+
from typing import Any, Literal, get_args, get_origin
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from dataface.core.compile.models.primitives import (
|
|
19
|
+
BorderStyle,
|
|
20
|
+
FontStyle,
|
|
21
|
+
MergedFontStyle,
|
|
22
|
+
SpacingValues,
|
|
23
|
+
StrokeStyle,
|
|
24
|
+
)
|
|
25
|
+
from dataface.core.compile.models.style.compiled import (
|
|
26
|
+
AreaChartStyle,
|
|
27
|
+
AutosizeStyle,
|
|
28
|
+
AxisStyle,
|
|
29
|
+
AxisStylePatch,
|
|
30
|
+
BarChartStyle,
|
|
31
|
+
BoardStyle,
|
|
32
|
+
BoxplotChartStyle,
|
|
33
|
+
CalloutChartStyle,
|
|
34
|
+
ChartsStyle,
|
|
35
|
+
DataTableStyle,
|
|
36
|
+
ErrorbandChartStyle,
|
|
37
|
+
ErrorbarChartStyle,
|
|
38
|
+
GeoshapeChartStyle,
|
|
39
|
+
GlobalMarksStyle,
|
|
40
|
+
HeatmapChartStyle,
|
|
41
|
+
HistogramChartStyle,
|
|
42
|
+
InferenceStyle,
|
|
43
|
+
KpiChartStyle,
|
|
44
|
+
LayoutStyle,
|
|
45
|
+
LegendStyle,
|
|
46
|
+
LineChartStyle,
|
|
47
|
+
PaddingStyle,
|
|
48
|
+
PageStyle,
|
|
49
|
+
PaginationConfig,
|
|
50
|
+
PieChartStyle,
|
|
51
|
+
PlaceholderStyle,
|
|
52
|
+
PointMapChartStyle,
|
|
53
|
+
ScaleStyle,
|
|
54
|
+
ScatterChartStyle,
|
|
55
|
+
SeriesLabelStyle,
|
|
56
|
+
SparkBarChartStyle,
|
|
57
|
+
SparkStyle,
|
|
58
|
+
Style,
|
|
59
|
+
TableChartStyle,
|
|
60
|
+
TextStyle,
|
|
61
|
+
TitleStyle,
|
|
62
|
+
TooltipStyle,
|
|
63
|
+
VariablesStyle,
|
|
64
|
+
ViewStyle,
|
|
65
|
+
)
|
|
66
|
+
from dataface.core.fonts import NOTO_COLOR_EMOJI_FONT_FAMILY, NOTO_EMOJI_FONT_FAMILY
|
|
67
|
+
|
|
68
|
+
_DEFAULT_ROOT_FONT = FontStyle(
|
|
69
|
+
family="Inter",
|
|
70
|
+
color="#222222",
|
|
71
|
+
size=14.0,
|
|
72
|
+
weight="400",
|
|
73
|
+
style="normal",
|
|
74
|
+
decoration="none",
|
|
75
|
+
case="none",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclasses.dataclass(frozen=True)
|
|
80
|
+
class MergedAxisElementStyle:
|
|
81
|
+
"""Merged axis label or title — all fields required, no defaults.
|
|
82
|
+
|
|
83
|
+
Chart-local Patch fields (label.angle, label.padding, etc.) flow through
|
|
84
|
+
the typed ``axis_overrides_*`` sentinels on ``MergedChartsStyle`` and
|
|
85
|
+
are merged at emit time by ``resolved_axis_style``. They never land on a
|
|
86
|
+
Merged axis directly.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
font: MergedFontStyle
|
|
90
|
+
padding: float
|
|
91
|
+
# Optional VL-passthrough fields — carried from theme through resolve boundary.
|
|
92
|
+
# None means "use VL default". Filled by _fill_axis cascade; passed through
|
|
93
|
+
# _build_resolved_axis. Mirrors AxisElementStyle passthrough fields.
|
|
94
|
+
max_width: float | None = None
|
|
95
|
+
angle: float | None = None
|
|
96
|
+
align: str | None = None
|
|
97
|
+
baseline: str | None = None
|
|
98
|
+
overlap: str | None = None
|
|
99
|
+
separation: float | None = None
|
|
100
|
+
visible: bool | None = None # None = cascade-inherit from parent axis slot
|
|
101
|
+
time_unit: str | None = None
|
|
102
|
+
expr: str | None = None
|
|
103
|
+
bound: bool | float | None = None
|
|
104
|
+
flush: bool | float | None = None
|
|
105
|
+
offset: float | None = None
|
|
106
|
+
line_height: float | None = None
|
|
107
|
+
anchor: str | None = None
|
|
108
|
+
tilt_increments: list[float] | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclasses.dataclass(frozen=True)
|
|
112
|
+
class MergedAxisGridZeroStyle:
|
|
113
|
+
color: str
|
|
114
|
+
width: float
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclasses.dataclass(frozen=True)
|
|
118
|
+
class MergedAxisGridStyle:
|
|
119
|
+
"""No defaults — must be explicitly constructed via resolve_style().
|
|
120
|
+
|
|
121
|
+
Same scope as MergedAxisElementStyle: chart-local grid overrides
|
|
122
|
+
(e.g. ``style.axis_y.grid.dash``) flow through ``axis_overrides_*``
|
|
123
|
+
and merge at emit time.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
visible: bool
|
|
127
|
+
opacity: float
|
|
128
|
+
width: float
|
|
129
|
+
color: str
|
|
130
|
+
zero: MergedAxisGridZeroStyle
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclasses.dataclass(frozen=True)
|
|
134
|
+
class MergedAxisDomainStyle:
|
|
135
|
+
visible: bool
|
|
136
|
+
width: float
|
|
137
|
+
color: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclasses.dataclass(frozen=True)
|
|
141
|
+
class MergedAxisTicksStyle:
|
|
142
|
+
visible: bool
|
|
143
|
+
color: str
|
|
144
|
+
# None means "use VL default" — kept distinct from a numeric override.
|
|
145
|
+
width: float | None
|
|
146
|
+
size: float | None
|
|
147
|
+
# Target tick count for quantitative axes. None → VL picks automatically.
|
|
148
|
+
count: int | None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclasses.dataclass(frozen=True)
|
|
152
|
+
class MergedAxisStyle:
|
|
153
|
+
"""Merged per-axis style — theme-only.
|
|
154
|
+
|
|
155
|
+
All fields come through resolve_style(). Chart-local axis overrides
|
|
156
|
+
(style.axis_x, style.axis_y, etc.) are NOT merged onto these fields —
|
|
157
|
+
they flow through the typed ``axis_overrides_*`` sentinels on
|
|
158
|
+
``MergedChartsStyle`` and merge at emit time via ``resolved_axis_style``.
|
|
159
|
+
Storing chart-local fields here would force the renderer to discriminate
|
|
160
|
+
authored vs theme values, which the cascade rename eliminates.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
grid: MergedAxisGridStyle
|
|
164
|
+
domain: MergedAxisDomainStyle
|
|
165
|
+
ticks: MergedAxisTicksStyle
|
|
166
|
+
label: MergedAxisElementStyle
|
|
167
|
+
title: MergedAxisElementStyle
|
|
168
|
+
orient: str | None # None is a valid concrete value ("use VL default")
|
|
169
|
+
categorical_orient: str | None
|
|
170
|
+
offset: float | None
|
|
171
|
+
scale: ScaleStyle | None # per-axis scale override (board cascade only)
|
|
172
|
+
fill: Literal[
|
|
173
|
+
"null",
|
|
174
|
+
"zero",
|
|
175
|
+
"linear",
|
|
176
|
+
"step-after",
|
|
177
|
+
"step-before",
|
|
178
|
+
"step-center",
|
|
179
|
+
"curve",
|
|
180
|
+
] # only active on axis_x ordinal bucketed-time charts
|
|
181
|
+
format: str | None # tick-label format string; None uses VL auto-format
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclasses.dataclass(frozen=True)
|
|
185
|
+
class MergedLegendElementStyle:
|
|
186
|
+
font: MergedFontStyle
|
|
187
|
+
padding: float
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclasses.dataclass(frozen=True)
|
|
191
|
+
class MergedLegendStyle:
|
|
192
|
+
"""Merged legend style.
|
|
193
|
+
|
|
194
|
+
``disable`` is a cascade-managed UX-default sentinel: None means
|
|
195
|
+
"legend visible" (neither theme nor chart suppressed it); True means
|
|
196
|
+
"explicitly suppressed" — set by the theme via _build_resolved_legend
|
|
197
|
+
or by a chart-local patch via build_resolved_style. The render layer
|
|
198
|
+
at _build_encoding_legend checks ``disable is True`` to emit VL
|
|
199
|
+
``legend: null``.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
orient: str
|
|
203
|
+
direction: str
|
|
204
|
+
label: MergedLegendElementStyle
|
|
205
|
+
title: MergedLegendElementStyle
|
|
206
|
+
interactive_legend: bool
|
|
207
|
+
disable: bool | None = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclasses.dataclass(frozen=True)
|
|
211
|
+
class MergedCalloutElementStyle:
|
|
212
|
+
font: MergedFontStyle
|
|
213
|
+
y_offset: float
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclasses.dataclass(frozen=True)
|
|
217
|
+
class MergedCalloutChartStyle:
|
|
218
|
+
tone: str | None
|
|
219
|
+
background: str
|
|
220
|
+
border: BorderStyle
|
|
221
|
+
padding: float
|
|
222
|
+
section_gap: float
|
|
223
|
+
title: MergedCalloutElementStyle
|
|
224
|
+
message: MergedCalloutElementStyle
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclasses.dataclass(frozen=True)
|
|
228
|
+
class MergedChartsStyle:
|
|
229
|
+
"""Merged charts — all fields required, no defaults.
|
|
230
|
+
|
|
231
|
+
Board-level fields (title, pagination, font_family) are carried here for the
|
|
232
|
+
VL config mapper (n()) which takes MergedChartsStyle, not the full
|
|
233
|
+
MergedStyle. They are required (not defaulted) to enforce that every
|
|
234
|
+
MergedChartsStyle is constructed by _build_resolved_charts().
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
palette: list[str]
|
|
238
|
+
# Dash palette for line-family marks. None means "no strokeDash encoding";
|
|
239
|
+
# themes that want dash-based categorical distinction set this. The render
|
|
240
|
+
# layer reads None and skips emission. Cascade-managed sentinel.
|
|
241
|
+
dashes: list[list[int]] | None
|
|
242
|
+
# Theme-resolved axis layers (Layers 1+2+3 of the cascade). build_resolved_style
|
|
243
|
+
# does NOT merge chart-local axis Patches into these — they're stored on
|
|
244
|
+
# axis_overrides_* below so resolved_axis_style can apply them in the canonical
|
|
245
|
+
# cascade order at emit time. Renderer reads the merged AxisStyle that
|
|
246
|
+
# resolved_axis_style returns, never the override Patches directly.
|
|
247
|
+
axis: MergedAxisStyle
|
|
248
|
+
axis_x: MergedAxisStyle
|
|
249
|
+
axis_y: MergedAxisStyle
|
|
250
|
+
axis_quantitative: MergedAxisStyle
|
|
251
|
+
legend: MergedLegendStyle
|
|
252
|
+
# Remaining sections keep compiled types (skeleton — centralize task fills these)
|
|
253
|
+
inference: InferenceStyle
|
|
254
|
+
# Global mark defaults (tier 1 of three-tier cascade). Renderers read mark
|
|
255
|
+
# fields from per-family chart styles via resolve_mark(charts.marks.<mark>,
|
|
256
|
+
# family.marks.<mark>). This field is also read by style_to_vega_lite for
|
|
257
|
+
# the VL config emit (circle, square, tick, rule, etc.).
|
|
258
|
+
marks: GlobalMarksStyle
|
|
259
|
+
bar: BarChartStyle
|
|
260
|
+
line: LineChartStyle
|
|
261
|
+
area: AreaChartStyle
|
|
262
|
+
scatter: ScatterChartStyle
|
|
263
|
+
view: ViewStyle
|
|
264
|
+
# Series-label primitive — shared typography for endpoint labels,
|
|
265
|
+
# direct stack labels, and slice callouts.
|
|
266
|
+
series_label: SeriesLabelStyle
|
|
267
|
+
pie: PieChartStyle
|
|
268
|
+
geoshape: GeoshapeChartStyle
|
|
269
|
+
point_map: PointMapChartStyle
|
|
270
|
+
histogram: HistogramChartStyle
|
|
271
|
+
heatmap: HeatmapChartStyle
|
|
272
|
+
boxplot: BoxplotChartStyle
|
|
273
|
+
errorbar: ErrorbarChartStyle
|
|
274
|
+
errorband: ErrorbandChartStyle
|
|
275
|
+
autosize: AutosizeStyle
|
|
276
|
+
# Remaining chart dimensions (not yet resolved to concrete layout values)
|
|
277
|
+
aspect_ratio: float
|
|
278
|
+
min_height: float
|
|
279
|
+
max_height: float
|
|
280
|
+
padding: PaddingStyle
|
|
281
|
+
border: BorderStyle
|
|
282
|
+
animation_duration: float
|
|
283
|
+
kpi: KpiChartStyle
|
|
284
|
+
table: TableChartStyle
|
|
285
|
+
spark: SparkStyle
|
|
286
|
+
spark_bar: SparkBarChartStyle
|
|
287
|
+
data_table: DataTableStyle
|
|
288
|
+
# Callout chart-family style (type: callout and runtime chart-error fallback)
|
|
289
|
+
callout: MergedCalloutChartStyle
|
|
290
|
+
# Authorable chart rendering settings — migrated from chart_rendering config
|
|
291
|
+
tooltip: TooltipStyle
|
|
292
|
+
font_family: str | None # root font.family; emitted as top-level VL `font`
|
|
293
|
+
title: TitleStyle
|
|
294
|
+
pagination: PaginationConfig | None
|
|
295
|
+
|
|
296
|
+
# Format alias vocabulary from the root Style.formats cascade.
|
|
297
|
+
# None = theme defines no format aliases. Render callsites pass this to
|
|
298
|
+
# resolve_format() so aliases resolve through the face→theme cascade.
|
|
299
|
+
formats: dict[str, str] | None = None
|
|
300
|
+
|
|
301
|
+
# Chart-local axis overrides (Layers 4/5/6) as typed AxisStylePatch
|
|
302
|
+
# sentinels — populated only by build_resolved_style from the chart's
|
|
303
|
+
# ChartStylePatch. None = no chart-local override on that axis variant.
|
|
304
|
+
# These are NOT exposed to chart emit code directly; resolved_axis_style is
|
|
305
|
+
# the single canonical reader, returning a fully-merged AxisStyle.
|
|
306
|
+
axis_overrides_global: AxisStylePatch | None = None
|
|
307
|
+
axis_overrides_x: AxisStylePatch | None = None
|
|
308
|
+
axis_overrides_y: AxisStylePatch | None = None
|
|
309
|
+
axis_overrides_quantitative: AxisStylePatch | None = None
|
|
310
|
+
axis_overrides_band: AxisStylePatch | None = None
|
|
311
|
+
# Chart-level scale override — populated only by build_resolved_style from
|
|
312
|
+
# style.scale. None = no chart-local global scale override.
|
|
313
|
+
scale: ScaleStyle | None = None
|
|
314
|
+
# Chart-local-only Vega passthroughs — populated by build_resolved_style
|
|
315
|
+
# from style.color / style.background / style.border. None = no chart-local
|
|
316
|
+
# override (renderer falls back to theme defaults).
|
|
317
|
+
color: str | None = None
|
|
318
|
+
# Chart-local background override — set by ChartStylePatch.background when
|
|
319
|
+
# a chart authors a per-chart background color. None = no chart-local override;
|
|
320
|
+
# standard_renderer._resolve_effective_background() falls back to face/theme.
|
|
321
|
+
background: str | None = None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclasses.dataclass(frozen=True)
|
|
325
|
+
class MergedStyle:
|
|
326
|
+
"""Final merged style — all fields required, no defaults.
|
|
327
|
+
|
|
328
|
+
The sole type accepted by the render/sizing layer. Every instance must
|
|
329
|
+
be produced by resolve_style() — never constructed directly.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
background: str
|
|
333
|
+
accent: str
|
|
334
|
+
muted: str
|
|
335
|
+
font: MergedFontStyle
|
|
336
|
+
border: BorderStyle
|
|
337
|
+
box_shadow: str | None # None = no shadow (valid concrete value)
|
|
338
|
+
opacity: float
|
|
339
|
+
board: BoardStyle
|
|
340
|
+
title: TitleStyle
|
|
341
|
+
text: TextStyle
|
|
342
|
+
placeholder: PlaceholderStyle
|
|
343
|
+
charts: MergedChartsStyle
|
|
344
|
+
layout: LayoutStyle
|
|
345
|
+
variables: VariablesStyle
|
|
346
|
+
page: PageStyle
|
|
347
|
+
# Per-face CSS-chrome. None = not authored; theme supplies no default.
|
|
348
|
+
padding: SpacingValues | None
|
|
349
|
+
margin: SpacingValues | None
|
|
350
|
+
gap: float | None
|
|
351
|
+
color: str | None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def effective_padding(resolved: MergedStyle) -> SpacingValues:
|
|
355
|
+
"""Return padding with border-occupancy added (half stroke inside the box)."""
|
|
356
|
+
pad = resolved.padding or SpacingValues()
|
|
357
|
+
bw = resolved.border.width
|
|
358
|
+
return SpacingValues(
|
|
359
|
+
top=pad.top + bw,
|
|
360
|
+
bottom=pad.bottom + bw,
|
|
361
|
+
left=pad.left + bw,
|
|
362
|
+
right=pad.right + bw,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# =============================================================================
|
|
367
|
+
# resolve_style
|
|
368
|
+
# =============================================================================
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _fill_font(target: FontStyle | None, source: FontStyle) -> FontStyle:
|
|
372
|
+
"""Fill None fields in target from source (cascade inheritance).
|
|
373
|
+
|
|
374
|
+
If target is None (per-family font not authored), return source as-is.
|
|
375
|
+
"""
|
|
376
|
+
if target is None:
|
|
377
|
+
return source
|
|
378
|
+
return FontStyle(
|
|
379
|
+
family=target.family if target.family is not None else source.family,
|
|
380
|
+
color=target.color if target.color is not None else source.color,
|
|
381
|
+
size=target.size if target.size is not None else source.size,
|
|
382
|
+
weight=target.weight if target.weight is not None else source.weight,
|
|
383
|
+
style=target.style if target.style is not None else source.style,
|
|
384
|
+
decoration=(
|
|
385
|
+
target.decoration if target.decoration is not None else source.decoration
|
|
386
|
+
),
|
|
387
|
+
case=target.case if target.case is not None else source.case,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def resolve_cascaded_font(font: FontStyle, path: str = "") -> MergedFontStyle:
|
|
392
|
+
"""Convert FontStyle to MergedFontStyle, raising if any required field is None."""
|
|
393
|
+
if (
|
|
394
|
+
font.family is None
|
|
395
|
+
or font.color is None
|
|
396
|
+
or font.size is None
|
|
397
|
+
or font.weight is None
|
|
398
|
+
or font.style is None
|
|
399
|
+
or font.decoration is None
|
|
400
|
+
or font.case is None
|
|
401
|
+
):
|
|
402
|
+
missing = [
|
|
403
|
+
f
|
|
404
|
+
for f, v in [
|
|
405
|
+
("family", font.family),
|
|
406
|
+
("color", font.color),
|
|
407
|
+
("size", font.size),
|
|
408
|
+
("weight", font.weight),
|
|
409
|
+
("style", font.style),
|
|
410
|
+
("decoration", font.decoration),
|
|
411
|
+
("case", font.case),
|
|
412
|
+
]
|
|
413
|
+
if v is None
|
|
414
|
+
]
|
|
415
|
+
raise ValueError(
|
|
416
|
+
f"Cannot resolve font{' at ' + path if path else ''}: "
|
|
417
|
+
f"missing required fields after cascade: {missing}"
|
|
418
|
+
)
|
|
419
|
+
return MergedFontStyle(
|
|
420
|
+
family=font.family,
|
|
421
|
+
color=font.color,
|
|
422
|
+
size=font.size,
|
|
423
|
+
weight=font.weight,
|
|
424
|
+
style=font.style,
|
|
425
|
+
decoration=font.decoration,
|
|
426
|
+
case=font.case,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# Dotted palette token: "<palette-name>.<slot>" — e.g. "dft-grays.gray-30",
|
|
431
|
+
# "negative.solid", "category-10.0". The leading character must be a letter so
|
|
432
|
+
# that hex literals (#abcdef), CSS rgb()/rgba() values, decimal numerics like
|
|
433
|
+
# "0.5", and quoted font-family lists never match.
|
|
434
|
+
#
|
|
435
|
+
# Contract: any string anywhere in Style that matches this regex is
|
|
436
|
+
# treated as a palette token. If a future non-color string field could match
|
|
437
|
+
# (none today), it must be excluded from the walk or the field reshaped.
|
|
438
|
+
_COLOR_TOKEN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*\.[A-Za-z0-9_-]+$")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# Theme-self tokens: strings that name a value elsewhere in the same compiled
|
|
442
|
+
# style tree, resolved against the root Style. The canonical use case
|
|
443
|
+
# is canvas coupling — any field that should track the theme's background
|
|
444
|
+
# color (knockout strokes, halos) sets the field to ``theme.background`` and
|
|
445
|
+
# the resolver substitutes the theme's actual ``style.background`` value.
|
|
446
|
+
#
|
|
447
|
+
# Resolution runs BEFORE ``_resolve_color_tokens`` so the substituted value
|
|
448
|
+
# (which may itself be a palette token like ``dft-creams.cream-025``) is
|
|
449
|
+
# carried through the color-token pass. Each theme resolves against its own
|
|
450
|
+
# ``style.background``, so cream's arc.stroke ends up at the cream
|
|
451
|
+
# canvas color while stark's stays white — without per-theme
|
|
452
|
+
# override duplication.
|
|
453
|
+
#
|
|
454
|
+
# Resolution runs at two points (mirroring _resolve_color_tokens): once at
|
|
455
|
+
# theme-load time inside ``get_compiled_theme()`` so cached themes carry
|
|
456
|
+
# concrete values, and again at the end of ``resolve_style()`` so face-level
|
|
457
|
+
# patches that introduce self-tokens are also resolved. A face that authors
|
|
458
|
+
# ``arc.stroke: theme.background`` in its own style block resolves the same
|
|
459
|
+
# way as the theme YAML did — no asymmetric contract.
|
|
460
|
+
_THEME_SELF_TOKENS = {"theme.background"}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _self_token_replacement(token: str, root: Style) -> str:
|
|
464
|
+
"""Look up a theme-self token's replacement value on ``root``.
|
|
465
|
+
|
|
466
|
+
``token`` is guaranteed to be a member of ``_THEME_SELF_TOKENS`` by the
|
|
467
|
+
caller; raises ``ValueError`` for any unknown token so a future
|
|
468
|
+
``_THEME_SELF_TOKENS`` addition without a matching branch fails loudly
|
|
469
|
+
instead of silently leaking the literal sentinel into compiled output.
|
|
470
|
+
"""
|
|
471
|
+
if token == "theme.background":
|
|
472
|
+
return root.background
|
|
473
|
+
raise ValueError(f"Unknown theme-self token: {token!r}")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _resolve_self_tokens(node: Any, root: Any | None = None) -> Any:
|
|
477
|
+
"""Walk a Style subtree and substitute theme-self tokens.
|
|
478
|
+
|
|
479
|
+
``root`` is the top-level Style the substitution looks values up
|
|
480
|
+
on; it defaults to ``node`` for the initial top-level call. The recursion
|
|
481
|
+
keeps the same root so deeply nested fields (e.g. ``charts.arc.stroke.color``)
|
|
482
|
+
resolve against the theme root, not against an enclosing sub-model.
|
|
483
|
+
"""
|
|
484
|
+
if root is None:
|
|
485
|
+
root = node
|
|
486
|
+
if isinstance(node, BaseModel):
|
|
487
|
+
updates: dict[str, Any] = {}
|
|
488
|
+
for name, value in node:
|
|
489
|
+
new = _resolve_self_tokens(value, root)
|
|
490
|
+
if new is not value:
|
|
491
|
+
updates[name] = new
|
|
492
|
+
return node.model_copy(update=updates) if updates else node
|
|
493
|
+
if isinstance(node, str):
|
|
494
|
+
if node in _THEME_SELF_TOKENS:
|
|
495
|
+
return _self_token_replacement(node, root)
|
|
496
|
+
return node
|
|
497
|
+
if isinstance(node, list):
|
|
498
|
+
new_list = [_resolve_self_tokens(item, root) for item in node]
|
|
499
|
+
return (
|
|
500
|
+
new_list
|
|
501
|
+
if any(a is not b for a, b in zip(new_list, node, strict=True))
|
|
502
|
+
else node
|
|
503
|
+
)
|
|
504
|
+
return node
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _resolve_one_color_token(
|
|
508
|
+
token: str,
|
|
509
|
+
palettes: dict[str, str] | None,
|
|
510
|
+
roles: dict[str, str] | None,
|
|
511
|
+
) -> str:
|
|
512
|
+
"""Resolve a single dotted color token to hex.
|
|
513
|
+
|
|
514
|
+
Tries the direct ``palette.slot`` path first. If the left-hand side is not
|
|
515
|
+
a known palette name the token is a role-indirected address (e.g.
|
|
516
|
+
``chrome.ink``); try ``color_from_theme()`` with the theme palettes/roles
|
|
517
|
+
context. Raises ``UnknownColorError`` if both paths fail.
|
|
518
|
+
"""
|
|
519
|
+
from dataface.core.compile.palette import (
|
|
520
|
+
UnknownColorError,
|
|
521
|
+
UnknownPaletteError,
|
|
522
|
+
color as resolve_palette_color,
|
|
523
|
+
color_from_theme,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
return resolve_palette_color(token)
|
|
528
|
+
except (UnknownColorError, UnknownPaletteError):
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
if palettes is not None:
|
|
532
|
+
return color_from_theme(token, palettes=palettes, roles=roles)
|
|
533
|
+
|
|
534
|
+
raise UnknownColorError(
|
|
535
|
+
f"color token '{token}' is not a known palette.slot address and no "
|
|
536
|
+
"theme palettes context is available for role-indirection lookup"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _resolve_color_tokens(
|
|
541
|
+
node: Any,
|
|
542
|
+
palettes: dict[str, str] | None = None,
|
|
543
|
+
roles: dict[str, str] | None = None,
|
|
544
|
+
) -> Any:
|
|
545
|
+
"""Walk a Style subtree and resolve every dotted palette token to hex.
|
|
546
|
+
|
|
547
|
+
Strings that don't look like a palette token (literal hex, rgb()/rgba(),
|
|
548
|
+
CSS named colors, font-family lists, etc.) pass through unchanged. Lists
|
|
549
|
+
are walked element-by-element so ``charts.palette`` slots that happen to
|
|
550
|
+
be tokens (rare) are also resolved. Dicts are not walked — no
|
|
551
|
+
color-bearing dicts exist on Style today; if one is added it
|
|
552
|
+
needs to be handled here explicitly.
|
|
553
|
+
|
|
554
|
+
``palettes`` and ``roles`` are extracted from the root Style at call time
|
|
555
|
+
and passed through the recursion so role-indirected tokens (e.g.
|
|
556
|
+
``chrome.ink`` where ``chrome`` maps to a palette name via the theme's
|
|
557
|
+
``palettes:`` block) resolve correctly alongside direct palette.slot tokens.
|
|
558
|
+
|
|
559
|
+
Run twice: once at theme-load time inside ``get_compiled_theme()`` so
|
|
560
|
+
cached themes carry hex, and again at the end of ``resolve_style()`` so
|
|
561
|
+
face-level patches that introduce tokens are also resolved.
|
|
562
|
+
"""
|
|
563
|
+
from dataface.core.compile.models.style.compiled import Style
|
|
564
|
+
|
|
565
|
+
if isinstance(node, Style):
|
|
566
|
+
# Extract palettes/roles context from the root Style for this walk.
|
|
567
|
+
palettes = node.palettes
|
|
568
|
+
roles = node.roles
|
|
569
|
+
if isinstance(node, BaseModel):
|
|
570
|
+
updates: dict[str, Any] = {}
|
|
571
|
+
for name, value in node:
|
|
572
|
+
new = _resolve_color_tokens(value, palettes, roles)
|
|
573
|
+
if new is not value:
|
|
574
|
+
updates[name] = new
|
|
575
|
+
return node.model_copy(update=updates) if updates else node
|
|
576
|
+
if isinstance(node, str):
|
|
577
|
+
if _COLOR_TOKEN_RE.match(node):
|
|
578
|
+
return _resolve_one_color_token(node, palettes, roles)
|
|
579
|
+
return node
|
|
580
|
+
if isinstance(node, list):
|
|
581
|
+
new_list = [_resolve_color_tokens(item, palettes, roles) for item in node]
|
|
582
|
+
return (
|
|
583
|
+
new_list
|
|
584
|
+
if any(a is not b for a, b in zip(new_list, node, strict=True))
|
|
585
|
+
else node
|
|
586
|
+
)
|
|
587
|
+
return node
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
_EMOJI_MODE_TO_FAMILY: dict[str, str] = {
|
|
591
|
+
"monochrome": NOTO_EMOJI_FONT_FAMILY,
|
|
592
|
+
"color": NOTO_COLOR_EMOJI_FONT_FAMILY,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def insert_emoji_family(family: str, emoji_family: str) -> str:
|
|
597
|
+
"""Insert ``emoji_family`` after the primary font in a CSS font-family stack.
|
|
598
|
+
|
|
599
|
+
Idempotent: no-op if ``emoji_family`` is already present. Public so callers in
|
|
600
|
+
typography.py / sizing.py / render can append an emoji family to a raw
|
|
601
|
+
family string without the full resolve_style() cascade walk.
|
|
602
|
+
"""
|
|
603
|
+
if emoji_family in family:
|
|
604
|
+
return family
|
|
605
|
+
# Assumes commas are CSS font-family stack separators, not quoted name characters.
|
|
606
|
+
first_comma = family.find(",")
|
|
607
|
+
if first_comma == -1:
|
|
608
|
+
return f"{family}, '{emoji_family}'"
|
|
609
|
+
return f"{family[:first_comma]}, '{emoji_family}'{family[first_comma:]}"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def apply_emoji_to_family(
|
|
613
|
+
raw: str,
|
|
614
|
+
emoji_mode: str,
|
|
615
|
+
) -> str:
|
|
616
|
+
"""Conditionally insert the appropriate emoji family into a font-family stack.
|
|
617
|
+
|
|
618
|
+
Maps ``emoji_mode`` to a bundled emoji font family name and inserts it.
|
|
619
|
+
Returns ``raw`` unchanged for ``system-default`` and ``disabled``.
|
|
620
|
+
"""
|
|
621
|
+
emoji_family = _EMOJI_MODE_TO_FAMILY.get(emoji_mode)
|
|
622
|
+
if emoji_family is None:
|
|
623
|
+
return raw
|
|
624
|
+
return insert_emoji_family(raw, emoji_family)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _append_emoji_family(node: Any, emoji_family: str) -> Any:
|
|
628
|
+
"""Walk a style subtree and append ``emoji_family`` to every non-None FontStyle.family.
|
|
629
|
+
|
|
630
|
+
Mirrors _resolve_color_tokens in structure: walks BaseModel trees recursively,
|
|
631
|
+
returning a patched copy only when a field actually changes.
|
|
632
|
+
FontStyle never appears inside a list/dict field in this schema; if that
|
|
633
|
+
changes, add a list branch mirroring _resolve_color_tokens.
|
|
634
|
+
"""
|
|
635
|
+
if isinstance(node, FontStyle):
|
|
636
|
+
if node.family is not None:
|
|
637
|
+
new_family = insert_emoji_family(node.family, emoji_family)
|
|
638
|
+
if new_family != node.family:
|
|
639
|
+
return node.model_copy(update={"family": new_family})
|
|
640
|
+
return node
|
|
641
|
+
if isinstance(node, BaseModel):
|
|
642
|
+
updates: dict[str, Any] = {}
|
|
643
|
+
for name, value in node:
|
|
644
|
+
new = _append_emoji_family(value, emoji_family)
|
|
645
|
+
if new is not value:
|
|
646
|
+
updates[name] = new
|
|
647
|
+
return node.model_copy(update=updates) if updates else node
|
|
648
|
+
return node
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _non_none_model_type(annotation: Any) -> type[BaseModel] | None:
|
|
652
|
+
"""From `T | None`, return T if T is a BaseModel subclass, else None."""
|
|
653
|
+
import typing
|
|
654
|
+
|
|
655
|
+
origin = get_origin(annotation)
|
|
656
|
+
if origin is types.UnionType or origin is typing.Union:
|
|
657
|
+
non_none = [a for a in get_args(annotation) if a is not type(None)]
|
|
658
|
+
if (
|
|
659
|
+
len(non_none) == 1
|
|
660
|
+
and isinstance(non_none[0], type)
|
|
661
|
+
and issubclass(non_none[0], BaseModel)
|
|
662
|
+
):
|
|
663
|
+
return non_none[0]
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def deep_merge(base: BaseModel, patch: Any | None) -> BaseModel:
|
|
668
|
+
"""Recursively merge a patch (all-Optional) onto a compiled base.
|
|
669
|
+
|
|
670
|
+
- Non-None patch fields win over base.
|
|
671
|
+
- Nested BaseModel fields are merged recursively (field-by-field), so a
|
|
672
|
+
patch that sets only axis.label.font.color still inherits the rest of
|
|
673
|
+
axis.label and axis from base.
|
|
674
|
+
- dict fields are merged key-wise: patch keys win, base keys not in patch survive.
|
|
675
|
+
This allows face.style.formats to override individual theme aliases without
|
|
676
|
+
replacing the entire dict.
|
|
677
|
+
- Scalars, lists, and Any-typed fields are replaced wholesale when non-None.
|
|
678
|
+
- None patch fields inherit from base.
|
|
679
|
+
- Patch fields whose name does not exist on base are ignored (the base type
|
|
680
|
+
defines the field set; this lets a Merged* base accept a bare-noun *Patch
|
|
681
|
+
that carries extra fields the merged type chose not to model).
|
|
682
|
+
|
|
683
|
+
Returns a new instance of the same type as base.
|
|
684
|
+
"""
|
|
685
|
+
if patch is None:
|
|
686
|
+
return base
|
|
687
|
+
result = {}
|
|
688
|
+
for field_name in type(base).model_fields:
|
|
689
|
+
base_val = getattr(base, field_name)
|
|
690
|
+
patch_val = getattr(patch, field_name, None)
|
|
691
|
+
if patch_val is None:
|
|
692
|
+
result[field_name] = base_val
|
|
693
|
+
elif isinstance(base_val, BaseModel) and isinstance(patch_val, BaseModel):
|
|
694
|
+
result[field_name] = deep_merge(base_val, patch_val)
|
|
695
|
+
elif isinstance(base_val, dict) and isinstance(patch_val, dict):
|
|
696
|
+
result[field_name] = {**base_val, **patch_val}
|
|
697
|
+
elif isinstance(patch_val, BaseModel):
|
|
698
|
+
# base_val is None — decide based on whether the compiled field type
|
|
699
|
+
# can be seeded from a partial patch dict (all fields have defaults).
|
|
700
|
+
# ScaleStyle and similar all-Optional compiled types: use the patch.
|
|
701
|
+
# TitleStyle and others with required fields: leave as None (callers
|
|
702
|
+
# like build_resolved_style extract those fields through a separate path).
|
|
703
|
+
ann = type(base).model_fields[field_name].annotation
|
|
704
|
+
target = _non_none_model_type(ann)
|
|
705
|
+
if target is not None and not any(
|
|
706
|
+
fi.is_required() for fi in target.model_fields.values()
|
|
707
|
+
):
|
|
708
|
+
result[field_name] = patch_val.model_dump(exclude_none=True)
|
|
709
|
+
else:
|
|
710
|
+
result[field_name] = None
|
|
711
|
+
else:
|
|
712
|
+
result[field_name] = patch_val
|
|
713
|
+
return type(base).model_validate(result)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
_M = Any # BaseModel subtype; TypeVar would require a bound import
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def resolve_mark(global_mark: _M, family_override: _M | None) -> _M:
|
|
720
|
+
"""Merge the global mark default with an optional family-level override.
|
|
721
|
+
|
|
722
|
+
Implements tier-1 + tier-2 of the three-tier mark cascade (global → family).
|
|
723
|
+
Face-level overrides (tier 3) are applied by build_resolved_style if present.
|
|
724
|
+
|
|
725
|
+
Returns ``global_mark`` unchanged when ``family_override`` is None.
|
|
726
|
+
"""
|
|
727
|
+
if family_override is None:
|
|
728
|
+
return global_mark
|
|
729
|
+
return deep_merge(global_mark, family_override)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _fill_none(model: _M, **tokens: Any) -> _M:
|
|
733
|
+
"""Fill only None fields of model from tokens; explicit values are preserved."""
|
|
734
|
+
updates = {f: v for f, v in tokens.items() if getattr(model, f) is None}
|
|
735
|
+
return model.model_copy(update=updates) if updates else model
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _fill_chart_base(family_style: _M, charts: ChartsStyle) -> _M:
|
|
739
|
+
"""Fill _ChartStyleBaseAllOptional sentinels from ChartsStyle global defaults.
|
|
740
|
+
|
|
741
|
+
Called for every per-family style in _apply_cascade. None sentinels on
|
|
742
|
+
the family style inherit the corresponding required value from ChartsStyle.
|
|
743
|
+
|
|
744
|
+
Only scalar fields are filled here. Nested BaseModel fields (legend, padding,
|
|
745
|
+
border, inference, tooltip) are now patch types (LegendStylePatch | None etc.)
|
|
746
|
+
due to is_recursive=True on _ChartStyleBaseAllOptional. Filling them with
|
|
747
|
+
compiled-type values (LegendStyle, PaddingStyle, etc.) would mix types.
|
|
748
|
+
Per-family legend/padding/etc. overrides authored in theme YAML are applied
|
|
749
|
+
in build_resolved_style via chart_type-aware patch application.
|
|
750
|
+
"""
|
|
751
|
+
return _fill_none(
|
|
752
|
+
family_style,
|
|
753
|
+
aspect_ratio=charts.aspect_ratio,
|
|
754
|
+
min_height=charts.min_height,
|
|
755
|
+
max_height=charts.max_height,
|
|
756
|
+
animation_duration=charts.animation_duration,
|
|
757
|
+
palette=charts.palette,
|
|
758
|
+
dashes=charts.dashes,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _cascade_family_marks(family: _M, global_marks: Any) -> _M:
|
|
763
|
+
"""Cascade global mark defaults into every mark slot of a family's marks container.
|
|
764
|
+
|
|
765
|
+
For each mark field shared between the family marks container and GlobalMarksStyle,
|
|
766
|
+
applies resolve_mark(global_val, family_val) so that:
|
|
767
|
+
- None family slots inherit the full global mark (including nested required fields).
|
|
768
|
+
- Non-None family slots are deep-merged on top of global, preserving fields the
|
|
769
|
+
family doesn't override (e.g. pie.marks.slice sets gap/stroke but not labels —
|
|
770
|
+
labels cascades from global so build_resolved_style always has a complete base).
|
|
771
|
+
"""
|
|
772
|
+
family_marks = family.marks
|
|
773
|
+
updates = {}
|
|
774
|
+
for field_name in type(family_marks).model_fields:
|
|
775
|
+
global_val = getattr(global_marks, field_name, None)
|
|
776
|
+
if global_val is None:
|
|
777
|
+
continue
|
|
778
|
+
updates[field_name] = resolve_mark(
|
|
779
|
+
global_val, getattr(family_marks, field_name)
|
|
780
|
+
)
|
|
781
|
+
if not updates:
|
|
782
|
+
return family
|
|
783
|
+
return family.model_copy(update={"marks": family_marks.model_copy(update=updates)})
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _fill_stroke_color(mark: Any, color: str) -> Any:
|
|
787
|
+
"""Fill mark.stroke.color from root font color when not already set.
|
|
788
|
+
|
|
789
|
+
Used for TickMarkStyle/RuleMarkStyle whose stroke.color cascades from
|
|
790
|
+
root font.color via _apply_token_cascade.
|
|
791
|
+
"""
|
|
792
|
+
if mark.stroke is None:
|
|
793
|
+
return mark.model_copy(update={"stroke": StrokeStyle(color=color)})
|
|
794
|
+
if mark.stroke.color is None:
|
|
795
|
+
return mark.model_copy(
|
|
796
|
+
update={"stroke": mark.stroke.model_copy(update={"color": color})}
|
|
797
|
+
)
|
|
798
|
+
return mark
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _apply_token_cascade(merged: Style) -> Style:
|
|
802
|
+
"""Fill None sub-fields from root semantic tokens (accent, muted, font.color).
|
|
803
|
+
|
|
804
|
+
Cascade invariant (ADR-009): token cascade runs ONLY inside resolve_style(),
|
|
805
|
+
after all face/chart patches have been merged. Running it earlier (e.g. at
|
|
806
|
+
theme load time) would fill sub-fields to concrete values, making them
|
|
807
|
+
invisible to _fill_none and silently dropping face-level token overrides.
|
|
808
|
+
|
|
809
|
+
Tokens: accent → spark.color, spark.bar.color, spark_bar.bar.color,
|
|
810
|
+
input.focus_color; muted → spark.bar.background,
|
|
811
|
+
spark_bar.bar_background; font.color → tick.stroke.color, rule.stroke.color.
|
|
812
|
+
"""
|
|
813
|
+
accent = merged.accent
|
|
814
|
+
muted = merged.muted
|
|
815
|
+
# _DEFAULT_ROOT_FONT.color is FontStyle.color (str | None), so spell out
|
|
816
|
+
# the literal fallback to keep root_color: str for mypy.
|
|
817
|
+
root_color: str = merged.font.color if merged.font.color is not None else "#222222"
|
|
818
|
+
|
|
819
|
+
spark = merged.charts.table.spark
|
|
820
|
+
new_spark = _fill_none(spark, color=accent).model_copy(
|
|
821
|
+
update={"bar": _fill_none(spark.bar, color=accent, background=muted)}
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
cascaded_marks = merged.charts.marks.model_copy(
|
|
825
|
+
update={
|
|
826
|
+
"tick": _fill_stroke_color(merged.charts.marks.tick, root_color),
|
|
827
|
+
"rule": _fill_stroke_color(merged.charts.marks.rule, root_color),
|
|
828
|
+
}
|
|
829
|
+
)
|
|
830
|
+
cascaded_charts = merged.charts.model_copy(
|
|
831
|
+
update={
|
|
832
|
+
"table": merged.charts.table.model_copy(update={"spark": new_spark}),
|
|
833
|
+
"spark_bar": merged.charts.spark_bar.model_copy(
|
|
834
|
+
update={
|
|
835
|
+
"bar": _fill_none(
|
|
836
|
+
merged.charts.spark_bar.bar, color=accent, background=muted
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
),
|
|
840
|
+
"marks": cascaded_marks,
|
|
841
|
+
}
|
|
842
|
+
)
|
|
843
|
+
return merged.model_copy(
|
|
844
|
+
update={
|
|
845
|
+
"charts": cascaded_charts,
|
|
846
|
+
"variables": merged.variables.model_copy(
|
|
847
|
+
update={"input": _fill_none(merged.variables.input, focus_color=accent)}
|
|
848
|
+
),
|
|
849
|
+
}
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _cascade_variables(variables: VariablesStyle, root: FontStyle) -> VariablesStyle:
|
|
854
|
+
"""Cascade root font → variables.font → variables.{label,value,placeholder}.font."""
|
|
855
|
+
variables_font = _fill_font(variables.font, root)
|
|
856
|
+
return variables.model_copy(
|
|
857
|
+
update={
|
|
858
|
+
"font": variables_font,
|
|
859
|
+
"label": variables.label.model_copy(
|
|
860
|
+
update={"font": _fill_font(variables.label.font, variables_font)}
|
|
861
|
+
),
|
|
862
|
+
"value": variables.value.model_copy(
|
|
863
|
+
update={"font": _fill_font(variables.value.font, variables_font)}
|
|
864
|
+
),
|
|
865
|
+
"placeholder": variables.placeholder.model_copy(
|
|
866
|
+
update={"font": _fill_font(variables.placeholder.font, variables_font)}
|
|
867
|
+
),
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# Fields on AxisElementStyle that are optional VL passthroughs:
|
|
873
|
+
# cascaded from parent axis to child (axis_x/y/etc.) in _fill_axis,
|
|
874
|
+
# then carried through _build_resolved_axis to MergedAxisElementStyle.
|
|
875
|
+
_ELEM_PASSTHROUGH = (
|
|
876
|
+
"max_width",
|
|
877
|
+
"angle",
|
|
878
|
+
"align",
|
|
879
|
+
"baseline",
|
|
880
|
+
"overlap",
|
|
881
|
+
"separation",
|
|
882
|
+
"visible",
|
|
883
|
+
"time_unit",
|
|
884
|
+
"expr",
|
|
885
|
+
"bound",
|
|
886
|
+
"flush",
|
|
887
|
+
"offset",
|
|
888
|
+
"line_height",
|
|
889
|
+
"anchor",
|
|
890
|
+
"tilt_increments",
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _apply_cascade(merged: Style) -> Style:
|
|
895
|
+
"""Apply semantic token cascade + ADR-003 font cascade.
|
|
896
|
+
|
|
897
|
+
Two-phase cascade applied after all patches are merged:
|
|
898
|
+
1. Token cascade (_apply_token_cascade): accent/muted/font.color → spark,
|
|
899
|
+
spark_bar, tick, rule, focus_color.
|
|
900
|
+
2. Font cascade: root font fills unset font fields at all text-bearing levels.
|
|
901
|
+
Hierarchy: root → charts_font → axis/legend/kpi/spark_bar/text/table;
|
|
902
|
+
table_font → table.header/title/more_rows/empty_state;
|
|
903
|
+
board_font → board.title.
|
|
904
|
+
|
|
905
|
+
Font cascade is deferred here (not at theme load time) so face-level font
|
|
906
|
+
overrides can propagate through the hierarchy — _fill_font only fills None
|
|
907
|
+
fields, so pre-filling at load time would prevent face patches propagating.
|
|
908
|
+
"""
|
|
909
|
+
# Phase 1: semantic token cascade
|
|
910
|
+
merged = _apply_token_cascade(merged)
|
|
911
|
+
|
|
912
|
+
# Phase 2: font cascade
|
|
913
|
+
# Fill the root font from the fallback, then put back a RootFontStyle
|
|
914
|
+
# so the emoji field (only present on the root, not on nested FontStyle) is
|
|
915
|
+
# preserved through the cascade for the emoji-injection gate in resolve_style.
|
|
916
|
+
_filled_root = _fill_font(merged.font, _DEFAULT_ROOT_FONT)
|
|
917
|
+
root = merged.font.model_copy(
|
|
918
|
+
update={
|
|
919
|
+
"family": _filled_root.family,
|
|
920
|
+
"color": _filled_root.color,
|
|
921
|
+
"size": _filled_root.size,
|
|
922
|
+
"weight": _filled_root.weight,
|
|
923
|
+
"style": _filled_root.style,
|
|
924
|
+
"decoration": _filled_root.decoration,
|
|
925
|
+
"case": _filled_root.case,
|
|
926
|
+
}
|
|
927
|
+
)
|
|
928
|
+
charts_font = _fill_font(merged.charts.font, root)
|
|
929
|
+
|
|
930
|
+
def _fill_axis(axis: AxisStyle, parent: AxisStyle | None = None) -> AxisStyle:
|
|
931
|
+
# When parent is supplied (axis_x/y/quantitative), every None field in
|
|
932
|
+
# grid / domain / ticks inherits from the canonical `axis` slot. This
|
|
933
|
+
# lets the base theme write only intentional differences in the child
|
|
934
|
+
# axis types — everything else cascades from `axis`.
|
|
935
|
+
grid = axis.grid
|
|
936
|
+
domain = axis.domain
|
|
937
|
+
ticks = axis.ticks
|
|
938
|
+
if parent is not None:
|
|
939
|
+
grid_updates = {
|
|
940
|
+
f: getattr(parent.grid, f)
|
|
941
|
+
for f in ("visible", "opacity", "width", "color")
|
|
942
|
+
if getattr(grid, f) is None and getattr(parent.grid, f) is not None
|
|
943
|
+
}
|
|
944
|
+
if grid_updates:
|
|
945
|
+
grid = grid.model_copy(update=grid_updates)
|
|
946
|
+
# Cascade zero sub-object field-by-field from parent axis slot.
|
|
947
|
+
if parent.grid.zero is not None:
|
|
948
|
+
if grid.zero is None:
|
|
949
|
+
grid = grid.model_copy(update={"zero": parent.grid.zero})
|
|
950
|
+
else:
|
|
951
|
+
zero_updates = {
|
|
952
|
+
f: getattr(parent.grid.zero, f)
|
|
953
|
+
for f in ("color", "width")
|
|
954
|
+
if getattr(grid.zero, f) is None
|
|
955
|
+
and getattr(parent.grid.zero, f) is not None
|
|
956
|
+
}
|
|
957
|
+
if zero_updates:
|
|
958
|
+
grid = grid.model_copy(
|
|
959
|
+
update={"zero": grid.zero.model_copy(update=zero_updates)}
|
|
960
|
+
)
|
|
961
|
+
domain_updates = {
|
|
962
|
+
f: getattr(parent.domain, f)
|
|
963
|
+
for f in ("visible", "width", "color")
|
|
964
|
+
if getattr(domain, f) is None and getattr(parent.domain, f) is not None
|
|
965
|
+
}
|
|
966
|
+
if domain_updates:
|
|
967
|
+
domain = domain.model_copy(update=domain_updates)
|
|
968
|
+
ticks_updates = {
|
|
969
|
+
f: getattr(parent.ticks, f)
|
|
970
|
+
for f in ("visible", "color", "size", "width")
|
|
971
|
+
if getattr(ticks, f) is None and getattr(parent.ticks, f) is not None
|
|
972
|
+
}
|
|
973
|
+
if ticks_updates:
|
|
974
|
+
ticks = ticks.model_copy(update=ticks_updates)
|
|
975
|
+
# label/title font cascade: per-axis font fills missing fields from the
|
|
976
|
+
# canonical `axis` parent first, then from charts_font. Without the
|
|
977
|
+
# parent step, an override on `charts.axis.label.font.color` would only
|
|
978
|
+
# land on `config.axis` and be clobbered by `config.axisX/Y` defaults.
|
|
979
|
+
label_parent_font = parent.label.font if parent is not None else charts_font
|
|
980
|
+
title_parent_font = parent.title.font if parent is not None else charts_font
|
|
981
|
+
label_padding = axis.label.padding
|
|
982
|
+
if label_padding is None and parent is not None:
|
|
983
|
+
label_padding = parent.label.padding
|
|
984
|
+
title_padding = axis.title.padding
|
|
985
|
+
if title_padding is None and parent is not None:
|
|
986
|
+
title_padding = parent.title.padding
|
|
987
|
+
if parent is not None:
|
|
988
|
+
label_passthrough = {
|
|
989
|
+
f: getattr(parent.label, f)
|
|
990
|
+
for f in _ELEM_PASSTHROUGH
|
|
991
|
+
if getattr(axis.label, f) is None
|
|
992
|
+
and getattr(parent.label, f) is not None
|
|
993
|
+
}
|
|
994
|
+
title_passthrough = {
|
|
995
|
+
f: getattr(parent.title, f)
|
|
996
|
+
for f in _ELEM_PASSTHROUGH
|
|
997
|
+
if getattr(axis.title, f) is None
|
|
998
|
+
and getattr(parent.title, f) is not None
|
|
999
|
+
}
|
|
1000
|
+
else:
|
|
1001
|
+
label_passthrough = {}
|
|
1002
|
+
title_passthrough = {}
|
|
1003
|
+
return axis.model_copy(
|
|
1004
|
+
update={
|
|
1005
|
+
"grid": grid,
|
|
1006
|
+
"domain": domain,
|
|
1007
|
+
"ticks": ticks,
|
|
1008
|
+
"label": axis.label.model_copy(
|
|
1009
|
+
update={
|
|
1010
|
+
"font": _fill_font(
|
|
1011
|
+
_fill_font(axis.label.font, label_parent_font),
|
|
1012
|
+
charts_font,
|
|
1013
|
+
),
|
|
1014
|
+
"padding": label_padding,
|
|
1015
|
+
**label_passthrough,
|
|
1016
|
+
}
|
|
1017
|
+
),
|
|
1018
|
+
"title": axis.title.model_copy(
|
|
1019
|
+
update={
|
|
1020
|
+
"font": _fill_font(
|
|
1021
|
+
_fill_font(axis.title.font, title_parent_font),
|
|
1022
|
+
charts_font,
|
|
1023
|
+
),
|
|
1024
|
+
"padding": title_padding,
|
|
1025
|
+
**title_passthrough,
|
|
1026
|
+
}
|
|
1027
|
+
),
|
|
1028
|
+
}
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
def _fill_legend(legend: LegendStyle) -> LegendStyle:
|
|
1032
|
+
return legend.model_copy(
|
|
1033
|
+
update={
|
|
1034
|
+
"label": legend.label.model_copy(
|
|
1035
|
+
update={"font": _fill_font(legend.label.font, charts_font)}
|
|
1036
|
+
),
|
|
1037
|
+
"title": legend.title.model_copy(
|
|
1038
|
+
update={"font": _fill_font(legend.title.font, charts_font)}
|
|
1039
|
+
),
|
|
1040
|
+
}
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
def _fill_callout_chart(
|
|
1044
|
+
callout: CalloutChartStyle,
|
|
1045
|
+
) -> CalloutChartStyle:
|
|
1046
|
+
return callout.model_copy(
|
|
1047
|
+
update={
|
|
1048
|
+
"title": callout.title.model_copy(
|
|
1049
|
+
update={"font": _fill_font(callout.title.font, charts_font)}
|
|
1050
|
+
),
|
|
1051
|
+
"message": callout.message.model_copy(
|
|
1052
|
+
update={"font": _fill_font(callout.message.font, charts_font)}
|
|
1053
|
+
),
|
|
1054
|
+
}
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
kpi_font = _fill_font(merged.charts.kpi.font, charts_font)
|
|
1058
|
+
cascaded_kpi = merged.charts.kpi.model_copy(
|
|
1059
|
+
update={
|
|
1060
|
+
"font": kpi_font,
|
|
1061
|
+
"value": merged.charts.kpi.value.model_copy(
|
|
1062
|
+
update={"font": _fill_font(merged.charts.kpi.value.font, kpi_font)}
|
|
1063
|
+
),
|
|
1064
|
+
"label": merged.charts.kpi.label.model_copy(
|
|
1065
|
+
update={"font": _fill_font(merged.charts.kpi.label.font, kpi_font)}
|
|
1066
|
+
),
|
|
1067
|
+
"affix": merged.charts.kpi.affix.model_copy(
|
|
1068
|
+
update={"font": _fill_font(merged.charts.kpi.affix.font, kpi_font)}
|
|
1069
|
+
),
|
|
1070
|
+
"glyph": merged.charts.kpi.glyph.model_copy(
|
|
1071
|
+
update={"font": _fill_font(merged.charts.kpi.glyph.font, kpi_font)}
|
|
1072
|
+
),
|
|
1073
|
+
}
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
cascaded_spark_bar = merged.charts.spark_bar.model_copy(
|
|
1077
|
+
update={"font": _fill_font(merged.charts.spark_bar.font, charts_font)}
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
table_font = _fill_font(merged.charts.table.font, charts_font)
|
|
1081
|
+
cascaded_bar = merged.charts.table.spark.bar.model_copy(
|
|
1082
|
+
update={"font": _fill_font(merged.charts.table.spark.bar.font, charts_font)}
|
|
1083
|
+
)
|
|
1084
|
+
cascaded_spark = merged.charts.table.spark.model_copy(update={"bar": cascaded_bar})
|
|
1085
|
+
cascaded_table = merged.charts.table.model_copy(
|
|
1086
|
+
update={
|
|
1087
|
+
"font": table_font,
|
|
1088
|
+
"header": merged.charts.table.header.model_copy(
|
|
1089
|
+
update={"font": _fill_font(merged.charts.table.header.font, table_font)}
|
|
1090
|
+
),
|
|
1091
|
+
"title_row": merged.charts.table.title_row.model_copy(
|
|
1092
|
+
update={
|
|
1093
|
+
"font": _fill_font(merged.charts.table.title_row.font, table_font)
|
|
1094
|
+
}
|
|
1095
|
+
),
|
|
1096
|
+
"more_rows": merged.charts.table.more_rows.model_copy(
|
|
1097
|
+
update={
|
|
1098
|
+
"font": _fill_font(merged.charts.table.more_rows.font, table_font)
|
|
1099
|
+
}
|
|
1100
|
+
),
|
|
1101
|
+
"empty_state": merged.charts.table.empty_state.model_copy(
|
|
1102
|
+
update={
|
|
1103
|
+
"font": _fill_font(merged.charts.table.empty_state.font, table_font)
|
|
1104
|
+
}
|
|
1105
|
+
),
|
|
1106
|
+
"spark": cascaded_spark,
|
|
1107
|
+
}
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
# data_table font cascade: charts_font → data_table.font,
|
|
1111
|
+
# then data_table.font → divider-less leaves (label).
|
|
1112
|
+
data_table_font = _fill_font(merged.charts.data_table.font, charts_font)
|
|
1113
|
+
cascaded_data_table = merged.charts.data_table.model_copy(
|
|
1114
|
+
update={
|
|
1115
|
+
"font": data_table_font,
|
|
1116
|
+
"label": merged.charts.data_table.label.model_copy(
|
|
1117
|
+
update={
|
|
1118
|
+
"font": _fill_font(
|
|
1119
|
+
merged.charts.data_table.label.font, data_table_font
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
),
|
|
1123
|
+
}
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
cascaded_axis = _fill_axis(merged.charts.axis)
|
|
1127
|
+
cascaded_pie_total = merged.charts.pie.total.model_copy(
|
|
1128
|
+
update={
|
|
1129
|
+
"value": merged.charts.pie.total.value.model_copy(
|
|
1130
|
+
update={
|
|
1131
|
+
"font": _fill_font(merged.charts.pie.total.value.font, charts_font)
|
|
1132
|
+
}
|
|
1133
|
+
),
|
|
1134
|
+
"label": merged.charts.pie.total.label.model_copy(
|
|
1135
|
+
update={
|
|
1136
|
+
"font": _fill_font(merged.charts.pie.total.label.font, charts_font)
|
|
1137
|
+
}
|
|
1138
|
+
),
|
|
1139
|
+
}
|
|
1140
|
+
)
|
|
1141
|
+
# Pie slice labels: live under pie.marks.slice.labels after ADR-015 migration.
|
|
1142
|
+
# PieChartMarksStyle.slice is Optional (None = no family-level override).
|
|
1143
|
+
# When slice is set, cascade the font into its labels.
|
|
1144
|
+
pie_marks = merged.charts.pie.marks
|
|
1145
|
+
pie_marks_slice = pie_marks.slice
|
|
1146
|
+
if pie_marks_slice is not None and pie_marks_slice.labels is not None:
|
|
1147
|
+
cascaded_slice_labels = pie_marks_slice.labels.model_copy(
|
|
1148
|
+
update={"font": _fill_font(pie_marks_slice.labels.font, charts_font)}
|
|
1149
|
+
)
|
|
1150
|
+
cascaded_pie_marks_slice = pie_marks_slice.model_copy(
|
|
1151
|
+
update={"labels": cascaded_slice_labels}
|
|
1152
|
+
)
|
|
1153
|
+
cascaded_pie_marks = pie_marks.model_copy(
|
|
1154
|
+
update={"slice": cascaded_pie_marks_slice}
|
|
1155
|
+
)
|
|
1156
|
+
cascaded_pie = merged.charts.pie.model_copy(
|
|
1157
|
+
update={"total": cascaded_pie_total, "marks": cascaded_pie_marks}
|
|
1158
|
+
)
|
|
1159
|
+
else:
|
|
1160
|
+
cascaded_pie = merged.charts.pie.model_copy(
|
|
1161
|
+
update={"total": cascaded_pie_total}
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
# Global marks — font cascade: text (renamed label) + slice labels.
|
|
1165
|
+
# tick/rule stroke.color already set by _apply_token_cascade (Phase 1).
|
|
1166
|
+
marks_updates: dict[str, Any] = {
|
|
1167
|
+
"text": merged.charts.marks.text.model_copy(
|
|
1168
|
+
update={"font": _fill_font(merged.charts.marks.text.font, charts_font)}
|
|
1169
|
+
),
|
|
1170
|
+
}
|
|
1171
|
+
if merged.charts.marks.slice.labels is not None:
|
|
1172
|
+
marks_updates["slice"] = merged.charts.marks.slice.model_copy(
|
|
1173
|
+
update={
|
|
1174
|
+
"labels": merged.charts.marks.slice.labels.model_copy(
|
|
1175
|
+
update={
|
|
1176
|
+
"font": _fill_font(
|
|
1177
|
+
merged.charts.marks.slice.labels.font, charts_font
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
)
|
|
1181
|
+
}
|
|
1182
|
+
)
|
|
1183
|
+
cascaded_marks = merged.charts.marks.model_copy(update=marks_updates)
|
|
1184
|
+
|
|
1185
|
+
_c = merged.charts # shorthand for the per-family fill calls below
|
|
1186
|
+
|
|
1187
|
+
cascaded_charts = merged.charts.model_copy(
|
|
1188
|
+
update={
|
|
1189
|
+
"font": charts_font,
|
|
1190
|
+
"axis": cascaded_axis,
|
|
1191
|
+
"axis_x": _fill_axis(merged.charts.axis_x, parent=cascaded_axis),
|
|
1192
|
+
"axis_y": _fill_axis(merged.charts.axis_y, parent=cascaded_axis),
|
|
1193
|
+
"axis_quantitative": _fill_axis(
|
|
1194
|
+
merged.charts.axis_quantitative, parent=cascaded_axis
|
|
1195
|
+
),
|
|
1196
|
+
"legend": _fill_legend(merged.charts.legend),
|
|
1197
|
+
"marks": cascaded_marks,
|
|
1198
|
+
"series_label": merged.charts.series_label.model_copy(
|
|
1199
|
+
update={
|
|
1200
|
+
"font": _fill_font(merged.charts.series_label.font, charts_font)
|
|
1201
|
+
}
|
|
1202
|
+
),
|
|
1203
|
+
# Per-family cascade: fill _ChartStyleBase sentinel fields
|
|
1204
|
+
# (height, width, aspect_ratio, min_height, max_height, padding,
|
|
1205
|
+
# border, animation_duration, palette, dashes, inference, legend,
|
|
1206
|
+
# tooltip) from ChartsStyle global defaults.
|
|
1207
|
+
# _cascade_family_marks also runs here: it deep-merges global mark
|
|
1208
|
+
# defaults into every slot of each family's marks container so that
|
|
1209
|
+
# build_resolved_style always has a complete compiled mark as the base
|
|
1210
|
+
# for face-level overrides (no base_val=None gaps in deep_merge).
|
|
1211
|
+
"bar": _cascade_family_marks(_fill_chart_base(_c.bar, _c), cascaded_marks),
|
|
1212
|
+
"line": _cascade_family_marks(
|
|
1213
|
+
_fill_chart_base(_c.line, _c), cascaded_marks
|
|
1214
|
+
),
|
|
1215
|
+
"area": _cascade_family_marks(
|
|
1216
|
+
_fill_chart_base(_c.area, _c), cascaded_marks
|
|
1217
|
+
),
|
|
1218
|
+
"scatter": _cascade_family_marks(
|
|
1219
|
+
_fill_chart_base(_c.scatter, _c), cascaded_marks
|
|
1220
|
+
),
|
|
1221
|
+
"histogram": _cascade_family_marks(
|
|
1222
|
+
_fill_chart_base(_c.histogram, _c), cascaded_marks
|
|
1223
|
+
),
|
|
1224
|
+
"heatmap": _cascade_family_marks(
|
|
1225
|
+
_fill_chart_base(_c.heatmap, _c), cascaded_marks
|
|
1226
|
+
),
|
|
1227
|
+
"boxplot": _cascade_family_marks(
|
|
1228
|
+
_fill_chart_base(_c.boxplot, _c), cascaded_marks
|
|
1229
|
+
),
|
|
1230
|
+
"errorbar": _cascade_family_marks(
|
|
1231
|
+
_fill_chart_base(_c.errorbar, _c), cascaded_marks
|
|
1232
|
+
),
|
|
1233
|
+
"errorband": _cascade_family_marks(
|
|
1234
|
+
_fill_chart_base(_c.errorband, _c), cascaded_marks
|
|
1235
|
+
),
|
|
1236
|
+
"geoshape": _cascade_family_marks(
|
|
1237
|
+
_fill_chart_base(_c.geoshape, _c), cascaded_marks
|
|
1238
|
+
),
|
|
1239
|
+
"point_map": _cascade_family_marks(
|
|
1240
|
+
_fill_chart_base(_c.point_map, _c), cascaded_marks
|
|
1241
|
+
),
|
|
1242
|
+
# For families with pre-existing font cascade, apply geometry fill
|
|
1243
|
+
# to the already-cascaded version so both passes take effect.
|
|
1244
|
+
"pie": _cascade_family_marks(
|
|
1245
|
+
_fill_chart_base(cascaded_pie, _c), cascaded_marks
|
|
1246
|
+
),
|
|
1247
|
+
"kpi": _fill_chart_base(cascaded_kpi, _c),
|
|
1248
|
+
"table": _fill_chart_base(cascaded_table, _c),
|
|
1249
|
+
"spark_bar": cascaded_spark_bar,
|
|
1250
|
+
"data_table": cascaded_data_table,
|
|
1251
|
+
"callout": _fill_callout_chart(merged.charts.callout),
|
|
1252
|
+
}
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
return merged.model_copy(
|
|
1256
|
+
update={
|
|
1257
|
+
"font": root,
|
|
1258
|
+
"title": merged.title.model_copy(
|
|
1259
|
+
update={
|
|
1260
|
+
"font": _fill_font(merged.title.font, root),
|
|
1261
|
+
"subtitle": merged.title.subtitle.model_copy(
|
|
1262
|
+
update={"font": _fill_font(merged.title.subtitle.font, root)}
|
|
1263
|
+
),
|
|
1264
|
+
}
|
|
1265
|
+
),
|
|
1266
|
+
"text": merged.text.model_copy(
|
|
1267
|
+
update={"font": _fill_font(merged.text.font, root)}
|
|
1268
|
+
),
|
|
1269
|
+
"placeholder": merged.placeholder.model_copy(
|
|
1270
|
+
update={
|
|
1271
|
+
"overlay": merged.placeholder.overlay.model_copy(
|
|
1272
|
+
update={
|
|
1273
|
+
"font": _fill_font(merged.placeholder.overlay.font, root)
|
|
1274
|
+
}
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
),
|
|
1278
|
+
"charts": cascaded_charts,
|
|
1279
|
+
"layout": merged.layout.model_copy(
|
|
1280
|
+
update={
|
|
1281
|
+
"tabs": merged.layout.tabs.model_copy(
|
|
1282
|
+
update={"font": _fill_font(merged.layout.tabs.font, root)}
|
|
1283
|
+
),
|
|
1284
|
+
"details": merged.layout.details.model_copy(
|
|
1285
|
+
update={"font": _fill_font(merged.layout.details.font, root)}
|
|
1286
|
+
),
|
|
1287
|
+
}
|
|
1288
|
+
),
|
|
1289
|
+
"variables": _cascade_variables(merged.variables, root),
|
|
1290
|
+
}
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _build_resolved_axis(axis: AxisStyle) -> MergedAxisStyle:
|
|
1295
|
+
def _require(value: Any, path: str) -> Any:
|
|
1296
|
+
if value is None:
|
|
1297
|
+
raise ValueError(
|
|
1298
|
+
f"{path} must be authored by the theme or cascade from axis"
|
|
1299
|
+
)
|
|
1300
|
+
return value
|
|
1301
|
+
|
|
1302
|
+
return MergedAxisStyle(
|
|
1303
|
+
grid=MergedAxisGridStyle(
|
|
1304
|
+
visible=_require(axis.grid.visible, "charts.axis.grid.visible"),
|
|
1305
|
+
opacity=_require(axis.grid.opacity, "charts.axis.grid.opacity"),
|
|
1306
|
+
width=_require(axis.grid.width, "charts.axis.grid.width"),
|
|
1307
|
+
color=_require(axis.grid.color, "charts.axis.grid.color"),
|
|
1308
|
+
zero=MergedAxisGridZeroStyle(
|
|
1309
|
+
color=_require(
|
|
1310
|
+
axis.grid.zero.color if axis.grid.zero else None,
|
|
1311
|
+
"charts.axis.grid.zero.color",
|
|
1312
|
+
),
|
|
1313
|
+
width=_require(
|
|
1314
|
+
axis.grid.zero.width if axis.grid.zero else None,
|
|
1315
|
+
"charts.axis.grid.zero.width",
|
|
1316
|
+
),
|
|
1317
|
+
),
|
|
1318
|
+
),
|
|
1319
|
+
domain=MergedAxisDomainStyle(
|
|
1320
|
+
visible=_require(axis.domain.visible, "charts.axis.domain.visible"),
|
|
1321
|
+
width=_require(axis.domain.width, "charts.axis.domain.width"),
|
|
1322
|
+
color=_require(axis.domain.color, "charts.axis.domain.color"),
|
|
1323
|
+
),
|
|
1324
|
+
ticks=MergedAxisTicksStyle(
|
|
1325
|
+
visible=_require(axis.ticks.visible, "charts.axis.ticks.visible"),
|
|
1326
|
+
color=_require(axis.ticks.color, "charts.axis.ticks.color"),
|
|
1327
|
+
width=axis.ticks.width,
|
|
1328
|
+
size=axis.ticks.size,
|
|
1329
|
+
count=axis.ticks.count,
|
|
1330
|
+
),
|
|
1331
|
+
label=MergedAxisElementStyle(
|
|
1332
|
+
font=resolve_cascaded_font(axis.label.font, "charts.axis.label.font"),
|
|
1333
|
+
padding=_require(axis.label.padding, "charts.axis.label.padding"),
|
|
1334
|
+
**{f: getattr(axis.label, f) for f in _ELEM_PASSTHROUGH},
|
|
1335
|
+
),
|
|
1336
|
+
title=MergedAxisElementStyle(
|
|
1337
|
+
font=resolve_cascaded_font(axis.title.font, "charts.axis.title.font"),
|
|
1338
|
+
padding=_require(axis.title.padding, "charts.axis.title.padding"),
|
|
1339
|
+
**{f: getattr(axis.title, f) for f in _ELEM_PASSTHROUGH},
|
|
1340
|
+
),
|
|
1341
|
+
orient=axis.orient,
|
|
1342
|
+
categorical_orient=axis.categorical_orient,
|
|
1343
|
+
offset=axis.offset,
|
|
1344
|
+
scale=axis.scale,
|
|
1345
|
+
fill=axis.fill,
|
|
1346
|
+
format=axis.format,
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _build_resolved_legend(legend: LegendStyle) -> MergedLegendStyle:
|
|
1351
|
+
return MergedLegendStyle(
|
|
1352
|
+
orient=legend.orient,
|
|
1353
|
+
direction=legend.direction,
|
|
1354
|
+
disable=legend.disable,
|
|
1355
|
+
interactive_legend=legend.interactive_legend,
|
|
1356
|
+
label=MergedLegendElementStyle(
|
|
1357
|
+
font=resolve_cascaded_font(legend.label.font, "charts.legend.label.font"),
|
|
1358
|
+
padding=legend.label.padding,
|
|
1359
|
+
),
|
|
1360
|
+
title=MergedLegendElementStyle(
|
|
1361
|
+
font=resolve_cascaded_font(legend.title.font, "charts.legend.title.font"),
|
|
1362
|
+
padding=legend.title.padding,
|
|
1363
|
+
),
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _build_resolved_callout_chart(
|
|
1368
|
+
callout: CalloutChartStyle,
|
|
1369
|
+
) -> MergedCalloutChartStyle:
|
|
1370
|
+
return MergedCalloutChartStyle(
|
|
1371
|
+
tone=callout.tone,
|
|
1372
|
+
background=callout.background,
|
|
1373
|
+
border=callout.border,
|
|
1374
|
+
padding=callout.padding,
|
|
1375
|
+
section_gap=callout.section_gap,
|
|
1376
|
+
title=MergedCalloutElementStyle(
|
|
1377
|
+
font=resolve_cascaded_font(callout.title.font, "charts.callout.title.font"),
|
|
1378
|
+
y_offset=callout.title.y_offset,
|
|
1379
|
+
),
|
|
1380
|
+
message=MergedCalloutElementStyle(
|
|
1381
|
+
font=resolve_cascaded_font(
|
|
1382
|
+
callout.message.font, "charts.callout.message.font"
|
|
1383
|
+
),
|
|
1384
|
+
y_offset=callout.message.y_offset,
|
|
1385
|
+
),
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _build_resolved_charts(
|
|
1390
|
+
charts: ChartsStyle,
|
|
1391
|
+
font_family: str | None,
|
|
1392
|
+
title: TitleStyle,
|
|
1393
|
+
spark: SparkStyle,
|
|
1394
|
+
pagination: PaginationConfig | None,
|
|
1395
|
+
formats: dict[str, str] | None = None,
|
|
1396
|
+
) -> MergedChartsStyle:
|
|
1397
|
+
# Axis/legend/callout are type-transformed; the rest are supplied by params or
|
|
1398
|
+
# copied directly from charts where the field name matches.
|
|
1399
|
+
_AXIS_LEGEND_ERROR = {
|
|
1400
|
+
"axis",
|
|
1401
|
+
"axis_x",
|
|
1402
|
+
"axis_y",
|
|
1403
|
+
"axis_quantitative",
|
|
1404
|
+
"legend",
|
|
1405
|
+
"callout",
|
|
1406
|
+
}
|
|
1407
|
+
_FROM_PARAMS = {
|
|
1408
|
+
"font_family",
|
|
1409
|
+
"title",
|
|
1410
|
+
"spark",
|
|
1411
|
+
"pagination",
|
|
1412
|
+
"formats",
|
|
1413
|
+
}
|
|
1414
|
+
# Chart-local-only fields that have no representation on ChartsStyle —
|
|
1415
|
+
# they're populated only by build_resolved_style from a chart's ChartStylePatch.
|
|
1416
|
+
# Default to None at theme-resolve time.
|
|
1417
|
+
_CHART_LOCAL_ONLY = {
|
|
1418
|
+
"scale",
|
|
1419
|
+
"axis_overrides_global",
|
|
1420
|
+
"axis_overrides_x",
|
|
1421
|
+
"axis_overrides_y",
|
|
1422
|
+
"axis_overrides_quantitative",
|
|
1423
|
+
"axis_overrides_band",
|
|
1424
|
+
"color",
|
|
1425
|
+
"background",
|
|
1426
|
+
}
|
|
1427
|
+
passthrough = {
|
|
1428
|
+
name: getattr(charts, name)
|
|
1429
|
+
for name in (f.name for f in dataclasses.fields(MergedChartsStyle))
|
|
1430
|
+
if name not in _AXIS_LEGEND_ERROR
|
|
1431
|
+
and name not in _FROM_PARAMS
|
|
1432
|
+
and name not in _CHART_LOCAL_ONLY
|
|
1433
|
+
}
|
|
1434
|
+
return MergedChartsStyle(
|
|
1435
|
+
**passthrough,
|
|
1436
|
+
axis=_build_resolved_axis(charts.axis),
|
|
1437
|
+
axis_x=_build_resolved_axis(charts.axis_x),
|
|
1438
|
+
axis_y=_build_resolved_axis(charts.axis_y),
|
|
1439
|
+
axis_quantitative=_build_resolved_axis(charts.axis_quantitative),
|
|
1440
|
+
legend=_build_resolved_legend(charts.legend),
|
|
1441
|
+
callout=_build_resolved_callout_chart(charts.callout),
|
|
1442
|
+
font_family=font_family,
|
|
1443
|
+
title=title,
|
|
1444
|
+
spark=spark,
|
|
1445
|
+
pagination=pagination,
|
|
1446
|
+
formats=formats,
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
_RESOLVED_STYLE_CACHE: dict[int, tuple[Style, MergedStyle]] = {}
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def clear_resolve_style_cache() -> None:
|
|
1454
|
+
"""Clear cached no-patch resolved styles after config reloads."""
|
|
1455
|
+
_RESOLVED_STYLE_CACHE.clear()
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _resolve_style_uncached(base: Style, *patches: Any) -> MergedStyle:
|
|
1459
|
+
"""Merge compiled base with patches, apply cascade, return resolved style.
|
|
1460
|
+
|
|
1461
|
+
1. Deep-merge: for each patch, non-None fields override the base.
|
|
1462
|
+
2. Cascade inheritance (ADR-003): font flows down from root.
|
|
1463
|
+
3. Convert FontStyle → MergedFontStyle at each level (raises if any field None).
|
|
1464
|
+
4. Return MergedStyle.
|
|
1465
|
+
"""
|
|
1466
|
+
merged = base
|
|
1467
|
+
for patch in patches:
|
|
1468
|
+
merged = deep_merge(merged, patch) # type: ignore[assignment]
|
|
1469
|
+
|
|
1470
|
+
cascaded = _apply_cascade(merged)
|
|
1471
|
+
cascaded = _resolve_self_tokens(cascaded)
|
|
1472
|
+
cascaded = _resolve_color_tokens(cascaded)
|
|
1473
|
+
|
|
1474
|
+
emoji_family = _EMOJI_MODE_TO_FAMILY.get(cascaded.font.emoji)
|
|
1475
|
+
if emoji_family is not None:
|
|
1476
|
+
cascaded = _append_emoji_family(cascaded, emoji_family)
|
|
1477
|
+
|
|
1478
|
+
pagination = cascaded.charts.table.pagination
|
|
1479
|
+
|
|
1480
|
+
# Fields that pass through unchanged from Style → MergedStyle.
|
|
1481
|
+
# font/charts require type transformation.
|
|
1482
|
+
_TRANSFORMED = {"font", "charts"}
|
|
1483
|
+
passthrough = {
|
|
1484
|
+
name: getattr(cascaded, name)
|
|
1485
|
+
for name in (f.name for f in dataclasses.fields(MergedStyle))
|
|
1486
|
+
if name not in _TRANSFORMED
|
|
1487
|
+
}
|
|
1488
|
+
resolved_root_font = resolve_cascaded_font(cascaded.font, "font")
|
|
1489
|
+
return MergedStyle(
|
|
1490
|
+
**passthrough,
|
|
1491
|
+
font=resolved_root_font,
|
|
1492
|
+
charts=_build_resolved_charts(
|
|
1493
|
+
cascaded.charts,
|
|
1494
|
+
font_family=resolved_root_font.family,
|
|
1495
|
+
title=cascaded.title,
|
|
1496
|
+
spark=cascaded.charts.table.spark,
|
|
1497
|
+
pagination=pagination,
|
|
1498
|
+
formats=cascaded.formats,
|
|
1499
|
+
),
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def resolve_style(base: Style, *patches: Any) -> MergedStyle:
|
|
1504
|
+
"""Merge compiled base with patches, apply cascade, return resolved style."""
|
|
1505
|
+
if patches:
|
|
1506
|
+
return _resolve_style_uncached(base, *patches)
|
|
1507
|
+
|
|
1508
|
+
cache_key = id(base)
|
|
1509
|
+
cached = _RESOLVED_STYLE_CACHE.get(cache_key)
|
|
1510
|
+
if cached is not None and cached[0] is base:
|
|
1511
|
+
return copy.deepcopy(cached[1])
|
|
1512
|
+
|
|
1513
|
+
resolved = _resolve_style_uncached(base)
|
|
1514
|
+
_RESOLVED_STYLE_CACHE[cache_key] = (base, resolved)
|
|
1515
|
+
return copy.deepcopy(resolved)
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
# =============================================================================
|
|
1519
|
+
# VEGA-LITE MAPPING (new nested → flat camelCase)
|
|
1520
|
+
# =============================================================================
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def style_to_vega_lite(
|
|
1524
|
+
charts: MergedChartsStyle,
|
|
1525
|
+
) -> Any: # -> vlc.VegaLiteConfig, avoid circular import
|
|
1526
|
+
"""Map MergedChartsStyle to VegaLiteConfig.
|
|
1527
|
+
|
|
1528
|
+
Translates nested Dataface field names to flat Vega-Lite camelCase.
|
|
1529
|
+
This is the sole place that knows about Vega-Lite naming.
|
|
1530
|
+
|
|
1531
|
+
Sole VL mapper. The overloaded style_to_vega_lite(Style) signature was removed.
|
|
1532
|
+
"""
|
|
1533
|
+
from dataface.core.compile.models.vega_lite import config as vlc
|
|
1534
|
+
|
|
1535
|
+
data: dict[str, Any] = {}
|
|
1536
|
+
|
|
1537
|
+
# Root font family → VL top-level `font` key
|
|
1538
|
+
if charts.font_family is not None:
|
|
1539
|
+
data["font"] = charts.font_family
|
|
1540
|
+
|
|
1541
|
+
# Color palette → range.category. (Dashes flow through encoding-level
|
|
1542
|
+
# `scale.range` on the strokeDash channel, not config.range — vl_convert's
|
|
1543
|
+
# Vega-Lite v6 ignores `range.dashPattern` even though the docs list it.)
|
|
1544
|
+
if charts.palette:
|
|
1545
|
+
data["range"] = {"category": charts.palette}
|
|
1546
|
+
|
|
1547
|
+
# Default mark color flows through config.range.category (palette[0]) and
|
|
1548
|
+
# per-mark palette stanzas for generic marks below.
|
|
1549
|
+
default_mark_color: str | None = charts.palette[0] if charts.palette else None
|
|
1550
|
+
|
|
1551
|
+
# axis/axisX/axisY/axisQuantitative/axisBand: all moved to encoding level.
|
|
1552
|
+
# profile.py:_build_encoding_axis layers global + channel + type-conditional.
|
|
1553
|
+
# No config.axis* keys emitted here.
|
|
1554
|
+
|
|
1555
|
+
# legend: moved to encoding.color.legend.* / encoding.size.legend.* etc.
|
|
1556
|
+
# profile.py:_build_encoding_legend handles theme + chart-local merge.
|
|
1557
|
+
# No config.legend key emitted here.
|
|
1558
|
+
|
|
1559
|
+
# bar/line/area/scatter: moved to spec.mark.{...} extended mark object.
|
|
1560
|
+
# profile.py:_map_mark/_build_mark_style handles theme + chart-local merge.
|
|
1561
|
+
# No config.bar/line/area/point keys emitted here.
|
|
1562
|
+
|
|
1563
|
+
# Generic mark types: map opacity/stroke/strokeWidth where set.
|
|
1564
|
+
# After ADR-015, these live under charts.marks.* instead of charts.*.
|
|
1565
|
+
def _mark_config(m: Any) -> dict[str, Any]:
|
|
1566
|
+
d: dict[str, Any] = {}
|
|
1567
|
+
if getattr(m, "opacity", None) is not None:
|
|
1568
|
+
d["opacity"] = m.opacity
|
|
1569
|
+
stroke = getattr(m, "stroke", None)
|
|
1570
|
+
if stroke is not None:
|
|
1571
|
+
if stroke.color is not None:
|
|
1572
|
+
d["stroke"] = stroke.color
|
|
1573
|
+
if stroke.width is not None:
|
|
1574
|
+
d["strokeWidth"] = stroke.width
|
|
1575
|
+
return d
|
|
1576
|
+
|
|
1577
|
+
# Solid marks (rect) take fill from palette[0]; line-shaped marks (rule,
|
|
1578
|
+
# trail) take stroke. circle/square/tick keep stroke-only mapping; their
|
|
1579
|
+
# default coloring flows through scale.range.category.
|
|
1580
|
+
_SOLID_MARKS = {"rect"}
|
|
1581
|
+
_LINE_MARKS = {"rule", "trail"}
|
|
1582
|
+
for mark_name, mark in [
|
|
1583
|
+
("circle", charts.marks.circle),
|
|
1584
|
+
("square", charts.marks.square),
|
|
1585
|
+
("tick", charts.marks.tick),
|
|
1586
|
+
("rule", charts.marks.rule),
|
|
1587
|
+
("trail", charts.marks.trail),
|
|
1588
|
+
("rect", charts.marks.rect),
|
|
1589
|
+
]:
|
|
1590
|
+
cfg = _mark_config(mark)
|
|
1591
|
+
if default_mark_color is not None:
|
|
1592
|
+
if mark_name in _SOLID_MARKS:
|
|
1593
|
+
cfg["fill"] = default_mark_color
|
|
1594
|
+
elif mark_name in _LINE_MARKS and "stroke" not in cfg:
|
|
1595
|
+
cfg["stroke"] = default_mark_color
|
|
1596
|
+
if cfg:
|
|
1597
|
+
data[mark_name] = vlc.MarkConfig.model_validate(cfg)
|
|
1598
|
+
|
|
1599
|
+
# path/shape/symbol: VL marks not modeled in ChartsStyle. Emit
|
|
1600
|
+
# the palette[0] default only — these marks are passed through to VL
|
|
1601
|
+
# with no other Dataface-owned fields.
|
|
1602
|
+
if default_mark_color is not None:
|
|
1603
|
+
data["path"] = vlc.MarkConfig.model_validate({"stroke": default_mark_color})
|
|
1604
|
+
data["shape"] = vlc.MarkConfig.model_validate({"stroke": default_mark_color})
|
|
1605
|
+
data["symbol"] = vlc.MarkConfig.model_validate({"fill": default_mark_color})
|
|
1606
|
+
|
|
1607
|
+
# View
|
|
1608
|
+
view = charts.view
|
|
1609
|
+
view_data: dict[str, Any] = {
|
|
1610
|
+
"continuousWidth": view.continuous_width,
|
|
1611
|
+
"continuousHeight": view.continuous_height,
|
|
1612
|
+
# Always emit stroke. ``None`` is meaningful here: VL's default view
|
|
1613
|
+
# stroke is ``"#ddd"``, so an absent key draws a default border. To
|
|
1614
|
+
# actually disable the bounding box, the spec must contain
|
|
1615
|
+
# ``stroke: null``.
|
|
1616
|
+
"stroke": view.stroke,
|
|
1617
|
+
}
|
|
1618
|
+
if view.discrete_width is not None:
|
|
1619
|
+
view_data["discreteWidth"] = view.discrete_width
|
|
1620
|
+
if view.discrete_height is not None:
|
|
1621
|
+
view_data["discreteHeight"] = view.discrete_height
|
|
1622
|
+
data["view"] = view_data
|
|
1623
|
+
|
|
1624
|
+
# Autosize
|
|
1625
|
+
autosize = charts.autosize
|
|
1626
|
+
data["autosize"] = {
|
|
1627
|
+
"type": autosize.type,
|
|
1628
|
+
"contains": autosize.contains,
|
|
1629
|
+
"resize": autosize.resize,
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
# Text mark (data labels, annotations) — renamed from label to marks.text
|
|
1633
|
+
text_mark = charts.marks.text
|
|
1634
|
+
tf_text = text_mark.font
|
|
1635
|
+
text_data: dict[str, Any] = {}
|
|
1636
|
+
# All TextMarkStyle fields are cascade tier sentinels — only emit when set.
|
|
1637
|
+
if text_mark.align is not None:
|
|
1638
|
+
text_data["align"] = text_mark.align
|
|
1639
|
+
if tf_text.family is not None:
|
|
1640
|
+
text_data["font"] = tf_text.family
|
|
1641
|
+
if tf_text.color is not None:
|
|
1642
|
+
text_data["fill"] = tf_text.color # VL text mark uses fill for color
|
|
1643
|
+
if tf_text.size is not None:
|
|
1644
|
+
text_data["fontSize"] = tf_text.size
|
|
1645
|
+
if tf_text.weight is not None:
|
|
1646
|
+
text_data["fontWeight"] = tf_text.weight
|
|
1647
|
+
if text_data:
|
|
1648
|
+
data["text"] = text_data
|
|
1649
|
+
|
|
1650
|
+
# Title (board/chart title styling)
|
|
1651
|
+
title = charts.title
|
|
1652
|
+
title_data: dict[str, Any] = {}
|
|
1653
|
+
tf = title.font
|
|
1654
|
+
# FontStyle fields are Optional — only emit when authored (not None)
|
|
1655
|
+
if tf.color is not None:
|
|
1656
|
+
title_data["color"] = tf.color
|
|
1657
|
+
if tf.family is not None:
|
|
1658
|
+
title_data["font"] = tf.family
|
|
1659
|
+
if tf.size is not None:
|
|
1660
|
+
title_data["fontSize"] = tf.size
|
|
1661
|
+
if tf.weight is not None:
|
|
1662
|
+
title_data["fontWeight"] = tf.weight
|
|
1663
|
+
title_data["anchor"] = title.position.anchor
|
|
1664
|
+
if title.position.angle is not None:
|
|
1665
|
+
title_data["angle"] = title.position.angle
|
|
1666
|
+
if title.position.offset is not None:
|
|
1667
|
+
title_data["offset"] = title.position.offset
|
|
1668
|
+
if title.position.baseline is not None:
|
|
1669
|
+
title_data["baseline"] = title.position.baseline
|
|
1670
|
+
sf = title.subtitle.font
|
|
1671
|
+
if sf.color is not None:
|
|
1672
|
+
title_data["subtitleColor"] = sf.color
|
|
1673
|
+
if sf.family is not None:
|
|
1674
|
+
title_data["subtitleFont"] = sf.family
|
|
1675
|
+
if sf.size is not None:
|
|
1676
|
+
title_data["subtitleFontSize"] = sf.size
|
|
1677
|
+
if sf.weight is not None:
|
|
1678
|
+
title_data["subtitleFontWeight"] = sf.weight
|
|
1679
|
+
if title_data:
|
|
1680
|
+
data["title"] = title_data
|
|
1681
|
+
|
|
1682
|
+
return vlc.VegaLiteConfig.model_validate(data)
|