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,1287 @@
|
|
|
1
|
+
"""Vega-Lite attachment for the chart.data_table primitive.
|
|
2
|
+
|
|
3
|
+
Post-pass on a chart-body Vega-Lite spec. Given a validated ChartDataTable
|
|
4
|
+
and the chart's x-encoding, emit:
|
|
5
|
+
- An optional strip-top divider rule (when divider.width > 0).
|
|
6
|
+
- One text layer per row — source rows use a format() calculate transform;
|
|
7
|
+
aggregate rows add a Vega-Lite aggregate transform grouped by x.
|
|
8
|
+
- N-1 inter-row rule layers when row.rule.width > 0.
|
|
9
|
+
- One label text layer per row that carries label: — emitted at the
|
|
10
|
+
y-axis tick label's x-anchor (right gutter for right-oriented axes,
|
|
11
|
+
left gutter for left-oriented axes), derived from the resolved axis_y.orient.
|
|
12
|
+
|
|
13
|
+
No new primitives: every output layer is standard Vega-Lite (mark: text,
|
|
14
|
+
mark: rule).
|
|
15
|
+
|
|
16
|
+
Y-positioning is pixel-literal (`{"y": {"value": <px>}}`), computed from
|
|
17
|
+
the spec's explicit height plus per-row offsets. Vega-Lite treats
|
|
18
|
+
`{"y": {"expr": "..."}}` as a SCALED data value (not a pixel literal),
|
|
19
|
+
so the previous expr-based approach collapsed every row to one pixel
|
|
20
|
+
position via the parent's quantitative y-scale. Spec.height must be set
|
|
21
|
+
when data_table is non-None — auto-sized specs have no anchor.
|
|
22
|
+
|
|
23
|
+
The caller owns the padding allocation. Compute the required height with
|
|
24
|
+
``data_table_strip_height`` and add it to the appropriate padding side
|
|
25
|
+
(``top`` for position=top, ``bottom`` for position=bottom) via
|
|
26
|
+
``bump_padding_top`` / ``bump_padding_bottom``. The renderer's downstream
|
|
27
|
+
finalize step respects the external padding kwarg when supplied.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
from typing import Any, Literal
|
|
34
|
+
|
|
35
|
+
from dataface.core.compile.models.chart.authored import (
|
|
36
|
+
CHART_DATA_TABLE_MAX_X_TICKS,
|
|
37
|
+
ChartDataTable,
|
|
38
|
+
ChartDataTableAggregate,
|
|
39
|
+
ChartDataTableAggregateOp,
|
|
40
|
+
ChartDataTablePerSeries,
|
|
41
|
+
)
|
|
42
|
+
from dataface.core.compile.models.style.compiled import DataTableStyle
|
|
43
|
+
from dataface.core.compile.models.style.merged import (
|
|
44
|
+
MergedChartsStyle,
|
|
45
|
+
deep_merge,
|
|
46
|
+
)
|
|
47
|
+
from dataface.core.compile.palette import resolve_dark_companion_stops
|
|
48
|
+
from dataface.core.render.format_utils import resolve_format
|
|
49
|
+
|
|
50
|
+
# Map authoring-surface aggregate names to Vega-Lite aggregate ops.
|
|
51
|
+
# G4 (spec §2.4) keeps authoring exact; the compiler is free to translate.
|
|
52
|
+
# Keys are the ChartDataTableAggregateOp Literal values; the test guard
|
|
53
|
+
# test_agg_op_map_keys_are_all_authoring_surface_ops enforces coverage.
|
|
54
|
+
_AGG_OP_TO_VL: dict[ChartDataTableAggregateOp, str] = {
|
|
55
|
+
"sum": "sum",
|
|
56
|
+
"avg": "mean",
|
|
57
|
+
"min": "min",
|
|
58
|
+
"max": "max",
|
|
59
|
+
"median": "median",
|
|
60
|
+
"count": "count",
|
|
61
|
+
"count_distinct": "distinct",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Row geometry. These are the compiler's constants — they live here rather
|
|
65
|
+
# than on the style model because they are emitter mechanics, not user-facing
|
|
66
|
+
# theme tokens. If a theme wants denser rows, padding.vertical lets it.
|
|
67
|
+
_ROW_HEIGHT_BASE = 14.0 # per-row pixel height before padding
|
|
68
|
+
_DIVIDER_GAP = 4.0 # vertical gap between divider rule and first row
|
|
69
|
+
# Row labels share the y-axis tick column (~50–70 px for typical numeric
|
|
70
|
+
# formats). 80 px accommodates labels up to ~12 chars at 11 px Inter without
|
|
71
|
+
# the label reaching visibly into the legend zone or over-shrinking the plot.
|
|
72
|
+
_LABEL_STUB_LIMIT_PX = 80.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _row_height(style: DataTableStyle) -> float:
|
|
76
|
+
padding = style.row.padding
|
|
77
|
+
return _ROW_HEIGHT_BASE + padding.vertical * 2.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _strip_height(style: DataTableStyle, n_rows: int) -> float:
|
|
81
|
+
"""Total pixel height for the attached strip (position-agnostic)."""
|
|
82
|
+
row_h = _row_height(style)
|
|
83
|
+
divider = (style.divider.width + _DIVIDER_GAP) if style.divider.width > 0 else 0.0
|
|
84
|
+
inter_row = (
|
|
85
|
+
style.row.rule.width * (n_rows - 1)
|
|
86
|
+
if style.row.rule.width > 0 and n_rows > 1
|
|
87
|
+
else 0.0
|
|
88
|
+
)
|
|
89
|
+
return (
|
|
90
|
+
style.padding_top + divider + row_h * n_rows + inter_row + style.padding_bottom
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def data_table_strip_height(
|
|
95
|
+
data_table: ChartDataTable | None,
|
|
96
|
+
style: DataTableStyle,
|
|
97
|
+
charts_style: MergedChartsStyle,
|
|
98
|
+
series_count: int = 0,
|
|
99
|
+
) -> float:
|
|
100
|
+
"""Pixel height to reserve on the appropriate padding side when attaching a strip.
|
|
101
|
+
|
|
102
|
+
For ``position: bottom``: includes the axis-label gap between the plot bottom
|
|
103
|
+
and the strip top; callers pass the result to ``padding.bottom``.
|
|
104
|
+
|
|
105
|
+
For ``position: top``: no axis gap (x-axis is below the plot); callers pass
|
|
106
|
+
the result to ``padding.top``.
|
|
107
|
+
|
|
108
|
+
Returns 0 when no strip is attached.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
data_table: The data_table block, or None for no strip.
|
|
112
|
+
style: Resolved DataTableStyle (must carry ``position``).
|
|
113
|
+
charts_style: Resolved MergedChartsStyle for axis offset calculation.
|
|
114
|
+
series_count: How many series each ``per_series:`` entry expands to.
|
|
115
|
+
All per_series entries on the same chart share one color domain,
|
|
116
|
+
so one int suffices for the strip-height accounting. Required
|
|
117
|
+
(>0) when any entry is ``ChartDataTablePerSeries`` — leaving it
|
|
118
|
+
at 0 with a per_series entry present silently under-sizes the
|
|
119
|
+
strip and the expanded rows render below the reserved padding.
|
|
120
|
+
ValueError is raised in that case rather than guessing 1.
|
|
121
|
+
"""
|
|
122
|
+
if data_table is None or not data_table.entries:
|
|
123
|
+
return 0.0
|
|
124
|
+
n_rows = 0
|
|
125
|
+
for entry in data_table.entries:
|
|
126
|
+
if isinstance(entry, ChartDataTablePerSeries):
|
|
127
|
+
if entry.by_measure:
|
|
128
|
+
n_rows += 1
|
|
129
|
+
else:
|
|
130
|
+
if series_count <= 0:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"data_table_strip_height needs series_count > 0 when any "
|
|
133
|
+
"entry is `per_series:` — pass the number of color-domain "
|
|
134
|
+
"series the chart expands to. Defaulting to 1 silently "
|
|
135
|
+
"under-sizes the strip and the expanded rows render below "
|
|
136
|
+
"the reserved padding."
|
|
137
|
+
)
|
|
138
|
+
n_rows += series_count
|
|
139
|
+
else:
|
|
140
|
+
n_rows += 1
|
|
141
|
+
strip_h = _strip_height(style, n_rows)
|
|
142
|
+
if style.position == "bottom":
|
|
143
|
+
return _axis_offset(charts_style, style) + strip_h
|
|
144
|
+
# top: x-axis is below the plot — no axis gap above the strip.
|
|
145
|
+
return strip_h
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _axis_offset(charts_style: MergedChartsStyle, style: DataTableStyle) -> float:
|
|
149
|
+
"""Pixel offset from plot bottom edge to the bottom of the x-axis block.
|
|
150
|
+
|
|
151
|
+
Sums whichever axis components are actually visible: a chart with the
|
|
152
|
+
title shown but labels suppressed still needs the title's height to
|
|
153
|
+
sit above the strip, otherwise the strip's first row collides with it.
|
|
154
|
+
|
|
155
|
+
label_max_lines × label height is reserved so yearly boundary ticks that
|
|
156
|
+
emit a two-element array (e.g. ['Jan', '2024']) do not overlap the strip
|
|
157
|
+
top edge. style.label_max_lines = 2 is the universal default; themes with
|
|
158
|
+
a year-only cadence may set 1.
|
|
159
|
+
"""
|
|
160
|
+
axis = charts_style.axis_x
|
|
161
|
+
label_h = (
|
|
162
|
+
axis.label.padding + axis.label.font.size * style.label_max_lines
|
|
163
|
+
if axis.label.font.size > 0
|
|
164
|
+
else 0.0
|
|
165
|
+
)
|
|
166
|
+
title_h = (
|
|
167
|
+
axis.title.padding + axis.title.font.size if axis.title.font.size > 0 else 0.0
|
|
168
|
+
)
|
|
169
|
+
return label_h + title_h
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _row_y_pixel(
|
|
173
|
+
index: int,
|
|
174
|
+
style: DataTableStyle,
|
|
175
|
+
charts_style: MergedChartsStyle,
|
|
176
|
+
spec_height: float,
|
|
177
|
+
) -> float:
|
|
178
|
+
"""Pixel y of the centroid of the ith row in the attached strip.
|
|
179
|
+
|
|
180
|
+
For ``position: bottom``: y > spec_height (strip below the plot). Row 0 is
|
|
181
|
+
closest to the plot (first below the divider); row index increases downward.
|
|
182
|
+
|
|
183
|
+
For ``position: top``: y < 0 (strip above the plot, in padding.top zone).
|
|
184
|
+
Row 0 is closest to the plot top (y nearest 0); row index increases upward
|
|
185
|
+
(y becomes more negative). No axis-offset is needed since the x-axis is below.
|
|
186
|
+
"""
|
|
187
|
+
divider_off = style.divider.width + _DIVIDER_GAP if style.divider.width > 0 else 0.0
|
|
188
|
+
row_h = _row_height(style)
|
|
189
|
+
inter_row = (
|
|
190
|
+
style.row.rule.width * index if style.row.rule.width > 0 and index > 0 else 0.0
|
|
191
|
+
)
|
|
192
|
+
if style.position == "bottom":
|
|
193
|
+
axis_off = _axis_offset(charts_style, style)
|
|
194
|
+
offset = (
|
|
195
|
+
style.padding_top
|
|
196
|
+
+ axis_off
|
|
197
|
+
+ divider_off
|
|
198
|
+
+ index * row_h
|
|
199
|
+
+ inter_row
|
|
200
|
+
+ row_h / 2.0
|
|
201
|
+
)
|
|
202
|
+
return spec_height + offset
|
|
203
|
+
# top: measure from y=0 (plot top) going upward (negative y).
|
|
204
|
+
# Row 0 is closest to the plot; row N is furthest above.
|
|
205
|
+
# Layout (from y=0 upward): padding_bottom, then rows (0…N), then padding_top.
|
|
206
|
+
# Divider sits just above y=0, between padding_bottom and rows.
|
|
207
|
+
dist_from_plot_top = (
|
|
208
|
+
style.padding_bottom + divider_off + index * row_h + inter_row + row_h / 2.0
|
|
209
|
+
)
|
|
210
|
+
return -dist_from_plot_top
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _divider_y_pixel(
|
|
214
|
+
style: DataTableStyle,
|
|
215
|
+
charts_style: MergedChartsStyle,
|
|
216
|
+
spec_height: float,
|
|
217
|
+
) -> float:
|
|
218
|
+
"""Pixel y of the divider rule.
|
|
219
|
+
|
|
220
|
+
For ``position: bottom``: divider sits between the axis and the strip rows
|
|
221
|
+
(just below axis labels), at spec_height + padding_top + axis_offset.
|
|
222
|
+
|
|
223
|
+
For ``position: top``: divider sits between the strip rows and the plot top
|
|
224
|
+
(just above y=0), at -(padding_bottom).
|
|
225
|
+
"""
|
|
226
|
+
if style.position == "bottom":
|
|
227
|
+
axis_off = _axis_offset(charts_style, style)
|
|
228
|
+
return spec_height + style.padding_top + axis_off
|
|
229
|
+
# top: divider is the boundary between strip and plot.
|
|
230
|
+
return -style.padding_bottom
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _inter_row_rule_y_pixel(
|
|
234
|
+
index: int,
|
|
235
|
+
style: DataTableStyle,
|
|
236
|
+
charts_style: MergedChartsStyle,
|
|
237
|
+
spec_height: float,
|
|
238
|
+
) -> float:
|
|
239
|
+
"""Pixel y of the inter-row rule between row `index` and row `index + 1`.
|
|
240
|
+
|
|
241
|
+
Centroid sits in the middle of the inter-row gap so the rule's stroke
|
|
242
|
+
splits evenly between the two adjacent rows; without the half-stroke
|
|
243
|
+
offset the rule biases visually toward the row above.
|
|
244
|
+
|
|
245
|
+
For ``position: bottom``: row y values increase downward, so the rule
|
|
246
|
+
between row 0 and row 1 is *below* row 0's centroid → add half-row +
|
|
247
|
+
half-stroke.
|
|
248
|
+
|
|
249
|
+
For ``position: top``: row y values are negative (above the plot) and
|
|
250
|
+
become *more negative* as index increases. The rule between row 0 and
|
|
251
|
+
row 1 must be *more negative* than row 0's centroid → subtract.
|
|
252
|
+
"""
|
|
253
|
+
row_y = _row_y_pixel(index, style, charts_style, spec_height)
|
|
254
|
+
half_gap = _row_height(style) / 2.0 + style.row.rule.width / 2.0
|
|
255
|
+
if style.position == "bottom":
|
|
256
|
+
return row_y + half_gap
|
|
257
|
+
# top: "between rows" is more negative (further above plot)
|
|
258
|
+
return row_y - half_gap
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _vl_format_calc(
|
|
262
|
+
source: str,
|
|
263
|
+
format_spec: str | None,
|
|
264
|
+
as_name: str,
|
|
265
|
+
formats: dict[str, str] | None = None,
|
|
266
|
+
) -> dict[str, Any]:
|
|
267
|
+
"""Build a Vega-Lite calculate transform that formats a source column."""
|
|
268
|
+
if format_spec is None:
|
|
269
|
+
# Pass-through: text layer binds directly to the source field.
|
|
270
|
+
return {"calculate": f"datum.{source}", "as": as_name}
|
|
271
|
+
# Resolve alias before emitting — format_spec may be a theme alias (e.g.
|
|
272
|
+
# "currency") rather than a raw d3 spec (e.g. "$,.2f").
|
|
273
|
+
resolved = resolve_format(format_spec, formats)
|
|
274
|
+
return {"calculate": f"format(datum.{source}, '{resolved}')", "as": as_name}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _wrap_base_as_layer(spec: dict[str, Any]) -> dict[str, Any]:
|
|
278
|
+
"""Lift a single-mark spec into {layer: [base, ...]} form if needed."""
|
|
279
|
+
if "layer" in spec:
|
|
280
|
+
return dict(spec)
|
|
281
|
+
base_layer: dict[str, Any] = {}
|
|
282
|
+
for key in ("mark", "encoding", "transform"):
|
|
283
|
+
if key in spec:
|
|
284
|
+
base_layer[key] = spec[key]
|
|
285
|
+
new_spec = {k: v for k, v in spec.items() if k not in ("mark", "encoding")}
|
|
286
|
+
# `transform` stays on the base layer; the attached layers carry their own.
|
|
287
|
+
if "transform" in new_spec:
|
|
288
|
+
del new_spec["transform"]
|
|
289
|
+
# Promote tooltip to spec-level so strip layers inherit it (PR #1877).
|
|
290
|
+
# Strip text marks have only x + a calc text field; without an inherited
|
|
291
|
+
# tooltip, hover shows the raw channel field (e.g. ``revenue: 100.5``)
|
|
292
|
+
# instead of titled-and-formatted output.
|
|
293
|
+
#
|
|
294
|
+
# Two paths:
|
|
295
|
+
# - If the base encoding carries an explicit tooltip array (legacy callers,
|
|
296
|
+
# unit tests that hand-feed one): promote it as-is.
|
|
297
|
+
# - Otherwise: synthesize from the base's x/y channels — the bar layer's
|
|
298
|
+
# channels carry title + format after this PR, so the synthesized array
|
|
299
|
+
# is the same content the legacy tooltip array used to carry.
|
|
300
|
+
#
|
|
301
|
+
# Dict comprehension (not .pop) — base_layer["encoding"] is a shared
|
|
302
|
+
# reference to spec["encoding"] and mutation would corrupt the input.
|
|
303
|
+
base_encoding = base_layer.get("encoding")
|
|
304
|
+
if isinstance(base_encoding, dict):
|
|
305
|
+
existing_tooltip = base_encoding.get("tooltip")
|
|
306
|
+
if existing_tooltip is not None:
|
|
307
|
+
base_layer["encoding"] = {
|
|
308
|
+
k: v for k, v in base_encoding.items() if k != "tooltip"
|
|
309
|
+
}
|
|
310
|
+
new_spec["encoding"] = {"tooltip": existing_tooltip}
|
|
311
|
+
else:
|
|
312
|
+
spec_tooltip = [
|
|
313
|
+
{k: enc[k] for k in ("field", "type", "title", "format") if k in enc}
|
|
314
|
+
for ch in ("x", "y")
|
|
315
|
+
if (enc := base_encoding.get(ch, {})) and enc.get("field")
|
|
316
|
+
]
|
|
317
|
+
if spec_tooltip:
|
|
318
|
+
new_spec["encoding"] = {"tooltip": spec_tooltip}
|
|
319
|
+
new_spec["layer"] = [base_layer]
|
|
320
|
+
return new_spec
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _shared_x_encoding(parent_x_enc: dict[str, Any]) -> dict[str, Any]:
|
|
324
|
+
"""Shared x-encoding for all attached layers.
|
|
325
|
+
|
|
326
|
+
Copies field, type, and (when present) timeUnit from the parent chart's
|
|
327
|
+
x-encoding so strip layers share the same scale type. Mismatched types
|
|
328
|
+
(e.g. parent temporal vs strip ordinal) cause a vl-convert null-deref.
|
|
329
|
+
|
|
330
|
+
bandPosition rules:
|
|
331
|
+
- temporal + timeUnit → 1.0 (right-edge anchor; pairs with align:right
|
|
332
|
+
so text's right edge lines up with the bar's
|
|
333
|
+
right wall — no dx centering for these).
|
|
334
|
+
- ordinal / nominal → 0.5 (explicit band-centre anchor; pairs with a
|
|
335
|
+
per-entry dx from _compute_data_table_entry_dx
|
|
336
|
+
to implement table-parity column centering:
|
|
337
|
+
right edge at band_centre + max_w/2).
|
|
338
|
+
- quantitative / plain temporal → omitted (continuous scale, no bands).
|
|
339
|
+
|
|
340
|
+
No axis: null — setting axis to None crashes vl-convert with a TypeError
|
|
341
|
+
in parseAxesAndHeaders. Omitting the axis key entirely is correct; Vega-Lite
|
|
342
|
+
infers axis rendering from context and the text mark does not need axis ticks.
|
|
343
|
+
"""
|
|
344
|
+
enc: dict[str, Any] = {"field": parent_x_enc["field"], "type": parent_x_enc["type"]}
|
|
345
|
+
if "timeUnit" in parent_x_enc:
|
|
346
|
+
enc["timeUnit"] = parent_x_enc["timeUnit"]
|
|
347
|
+
enc["bandPosition"] = 1.0
|
|
348
|
+
elif parent_x_enc["type"] in ("ordinal", "nominal"):
|
|
349
|
+
enc["bandPosition"] = 0.5
|
|
350
|
+
return enc
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _text_mark_props(style: DataTableStyle, dx: float | None = None) -> dict[str, Any]:
|
|
354
|
+
"""Common mark properties for row-text layers.
|
|
355
|
+
|
|
356
|
+
dx, when set, offsets the text mark so the number lane's centre aligns
|
|
357
|
+
with the band midpoint — the same centre-on-midpoint invariant as
|
|
358
|
+
_compute_lane_positions in table.py. Caller passes
|
|
359
|
+
dx = max_formatted_width / 2 (always positive).
|
|
360
|
+
|
|
361
|
+
Sign convention (mirrors table lane geometry):
|
|
362
|
+
align=right → +dx (right edge at band_center + max_w/2 → column centred)
|
|
363
|
+
align=left → -dx (left edge at band_center - max_w/2 → column centred)
|
|
364
|
+
"""
|
|
365
|
+
align_map = {"left": "left", "right": "right", "decimal": "right"}
|
|
366
|
+
mark: dict[str, Any] = {
|
|
367
|
+
"type": "text",
|
|
368
|
+
"align": align_map[style.number_align],
|
|
369
|
+
"baseline": "middle",
|
|
370
|
+
}
|
|
371
|
+
if dx is not None:
|
|
372
|
+
# Left-aligned text anchors on its left edge; negate so the column
|
|
373
|
+
# centre sits at the band midpoint rather than shifting right.
|
|
374
|
+
mark["dx"] = -dx if style.number_align == "left" else dx
|
|
375
|
+
font = style.font
|
|
376
|
+
if font.family is not None:
|
|
377
|
+
mark["font"] = font.family
|
|
378
|
+
if font.size is not None:
|
|
379
|
+
mark["fontSize"] = font.size
|
|
380
|
+
if font.weight is not None:
|
|
381
|
+
mark["fontWeight"] = font.weight
|
|
382
|
+
if font.color is not None:
|
|
383
|
+
mark["fill"] = font.color
|
|
384
|
+
return mark
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _row_text_layer(
|
|
388
|
+
index: int,
|
|
389
|
+
entry: Any,
|
|
390
|
+
parent_x_enc: dict[str, Any],
|
|
391
|
+
style: DataTableStyle,
|
|
392
|
+
charts_style: MergedChartsStyle,
|
|
393
|
+
spec_height: float,
|
|
394
|
+
sampling_step: int = 1,
|
|
395
|
+
dx: float | None = None,
|
|
396
|
+
label_period_filter_expr: str | None = None,
|
|
397
|
+
formats: dict[str, str] | None = None,
|
|
398
|
+
) -> dict[str, Any]:
|
|
399
|
+
"""Emit a text layer for one data_table row (source or aggregate).
|
|
400
|
+
|
|
401
|
+
When sampling_step > 1, a Vega-Lite window+filter chain is inserted so
|
|
402
|
+
only every Nth x position renders a cell. Transform ordering matters:
|
|
403
|
+
- For aggregate entries: [aggregate, (period_filter), window, filter, format_calc]
|
|
404
|
+
The aggregate collapses multi-row-per-x data first; period filter then thins
|
|
405
|
+
to label-period openers; sampling then thins further if needed.
|
|
406
|
+
- For source entries (1 row per x, guaranteed by G1): [(period_filter), window, filter, format_calc]
|
|
407
|
+
|
|
408
|
+
When label_period_filter_expr is set, a filter transform is inserted to
|
|
409
|
+
keep only x-values that open a new label period (e.g. quarterly openers on
|
|
410
|
+
a monthly-band axis). This prevents the data_table strip from rendering one
|
|
411
|
+
cell per band when the axis labels are at a coarser cadence.
|
|
412
|
+
"""
|
|
413
|
+
is_agg = isinstance(entry, ChartDataTableAggregate)
|
|
414
|
+
x_field = parent_x_enc["field"]
|
|
415
|
+
# Internal field name for the formatted cell value.
|
|
416
|
+
cell_name = f"__data_table_{index}"
|
|
417
|
+
|
|
418
|
+
transforms: list[dict[str, Any]] = []
|
|
419
|
+
if is_agg:
|
|
420
|
+
vl_op = _AGG_OP_TO_VL[entry.aggregate]
|
|
421
|
+
agg_name = f"{cell_name}_val"
|
|
422
|
+
transforms.append(
|
|
423
|
+
{
|
|
424
|
+
"aggregate": [{"op": vl_op, "field": entry.source, "as": agg_name}],
|
|
425
|
+
"groupby": [x_field],
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
# Period filter after aggregate: one row per x, filter to label-period openers.
|
|
429
|
+
if label_period_filter_expr is not None:
|
|
430
|
+
transforms.append({"filter": label_period_filter_expr})
|
|
431
|
+
# Sampling after period filter: thin further if still over the cap.
|
|
432
|
+
if sampling_step > 1:
|
|
433
|
+
transforms.extend(_sampling_transforms(x_field, sampling_step))
|
|
434
|
+
if entry.format is not None:
|
|
435
|
+
transforms.append(
|
|
436
|
+
_vl_format_calc(agg_name, entry.format, cell_name, formats)
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
# pass raw aggregate value through as text
|
|
440
|
+
cell_name = agg_name
|
|
441
|
+
else:
|
|
442
|
+
# Source entry: G1 guarantees 1 row per x.
|
|
443
|
+
# Period filter before sampling: thin to label-period openers first.
|
|
444
|
+
if label_period_filter_expr is not None:
|
|
445
|
+
transforms.append({"filter": label_period_filter_expr})
|
|
446
|
+
if sampling_step > 1:
|
|
447
|
+
transforms.extend(_sampling_transforms(x_field, sampling_step))
|
|
448
|
+
if entry.format is not None:
|
|
449
|
+
transforms.append(
|
|
450
|
+
_vl_format_calc(entry.source, entry.format, cell_name, formats)
|
|
451
|
+
)
|
|
452
|
+
else:
|
|
453
|
+
cell_name = entry.source
|
|
454
|
+
|
|
455
|
+
y_pixel = _row_y_pixel(index, style, charts_style, spec_height)
|
|
456
|
+
encoding: dict[str, Any] = {
|
|
457
|
+
"x": _shared_x_encoding(parent_x_enc),
|
|
458
|
+
"y": {"value": y_pixel},
|
|
459
|
+
"text": {"field": cell_name},
|
|
460
|
+
}
|
|
461
|
+
layer: dict[str, Any] = {
|
|
462
|
+
"mark": _text_mark_props(style, dx=dx),
|
|
463
|
+
"encoding": encoding,
|
|
464
|
+
}
|
|
465
|
+
if transforms:
|
|
466
|
+
layer["transform"] = transforms
|
|
467
|
+
return layer
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _per_series_row_layers(
|
|
471
|
+
row_start_index: int,
|
|
472
|
+
entry: ChartDataTablePerSeries,
|
|
473
|
+
*,
|
|
474
|
+
parent_x_enc: dict[str, Any],
|
|
475
|
+
color_field: str | None,
|
|
476
|
+
series_order: list[str],
|
|
477
|
+
series_palette: list[str] | None,
|
|
478
|
+
style: DataTableStyle,
|
|
479
|
+
charts_style: MergedChartsStyle,
|
|
480
|
+
spec_height: float,
|
|
481
|
+
spec_width: float | None,
|
|
482
|
+
sampling_step: int = 1,
|
|
483
|
+
dx: float | None = None,
|
|
484
|
+
label_period_filter_expr: str | None = None,
|
|
485
|
+
axis_y_orient: Literal["left", "right"],
|
|
486
|
+
label_limit: float | None = None,
|
|
487
|
+
formats: dict[str, str] | None = None,
|
|
488
|
+
) -> list[dict[str, Any]]:
|
|
489
|
+
"""Emit one text layer + one label layer per series for a per_series entry.
|
|
490
|
+
|
|
491
|
+
When ``entry.by_measure`` is True, emits a single row reading the named
|
|
492
|
+
measure field directly (no color groupby/filter). Otherwise expands into
|
|
493
|
+
one text layer + one label layer per series name in series_order.
|
|
494
|
+
|
|
495
|
+
For the normal (non-by_measure) path:
|
|
496
|
+
- Each layer has an aggregate transform groupby [x, color_field].
|
|
497
|
+
- A filter transform selects the specific series.
|
|
498
|
+
- The label layer carries the series name with fill = dark companion ink.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
row_start_index: The pixel-row index of the first series row.
|
|
502
|
+
entry: The ChartDataTablePerSeries entry to expand.
|
|
503
|
+
parent_x_enc: The parent chart's x-encoding dict.
|
|
504
|
+
color_field: The column name used for the color: channel. Required for
|
|
505
|
+
normal mode; may be None when ``entry.by_measure`` is True.
|
|
506
|
+
series_order: Ordered series names (stack/scale order).
|
|
507
|
+
series_palette: The effective palette colours for each series in
|
|
508
|
+
series_order. When None, label fill is omitted (no companion resolution).
|
|
509
|
+
style: Resolved DataTableStyle.
|
|
510
|
+
charts_style: Resolved MergedChartsStyle.
|
|
511
|
+
spec_height: The chart spec's pixel height.
|
|
512
|
+
spec_width: The chart spec's pixel width. Required (non-None) when
|
|
513
|
+
``axis_y_orient == "right"``; ignored for left-cap.
|
|
514
|
+
sampling_step: Sampling step for dense x axes (>1 thins cells).
|
|
515
|
+
dx: Optional band-centering dx for cell text marks.
|
|
516
|
+
axis_y_orient: Resolved y-axis orient — "right" or "left". Determines
|
|
517
|
+
which label emitter fires and the mark's x-anchor and text-anchor.
|
|
518
|
+
label_limit: mark.limit (pixels) applied to every label layer. When None,
|
|
519
|
+
no limit is set. Pass _LABEL_STUB_LIMIT_PX to prevent labels from
|
|
520
|
+
reaching into the legend zone.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Flat list of Vega-Lite layer dicts (cell text + label text per series).
|
|
524
|
+
|
|
525
|
+
Raises:
|
|
526
|
+
ValueError when ``axis_y_orient == "right"`` but ``spec_width`` is None.
|
|
527
|
+
"""
|
|
528
|
+
if axis_y_orient == "right" and spec_width is None:
|
|
529
|
+
raise ValueError(
|
|
530
|
+
"attach_data_table requires the spec to carry an explicit "
|
|
531
|
+
"width when right-cap labels are present on per_series rows — "
|
|
532
|
+
"pixel-literal label positioning has no anchor otherwise."
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# by_measure mode: one row reading the named field directly.
|
|
536
|
+
if entry.by_measure:
|
|
537
|
+
row_idx = row_start_index
|
|
538
|
+
bm_cell_name = f"__data_table_{row_idx}"
|
|
539
|
+
bm_transforms: list[dict[str, Any]] = []
|
|
540
|
+
if label_period_filter_expr is not None:
|
|
541
|
+
bm_transforms.append({"filter": label_period_filter_expr})
|
|
542
|
+
if sampling_step > 1:
|
|
543
|
+
bm_transforms.extend(
|
|
544
|
+
_sampling_transforms(parent_x_enc["field"], sampling_step)
|
|
545
|
+
)
|
|
546
|
+
if entry.format is not None:
|
|
547
|
+
bm_transforms.append(
|
|
548
|
+
{
|
|
549
|
+
"calculate": f"format(datum.{entry.per_series}, '{entry.format}')",
|
|
550
|
+
"as": bm_cell_name,
|
|
551
|
+
}
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
bm_cell_name = entry.per_series
|
|
555
|
+
bm_y_pixel = _row_y_pixel(row_idx, style, charts_style, spec_height)
|
|
556
|
+
bm_cell_layer: dict[str, Any] = {
|
|
557
|
+
"mark": _text_mark_props(style, dx=dx),
|
|
558
|
+
"encoding": {
|
|
559
|
+
"x": _shared_x_encoding(parent_x_enc),
|
|
560
|
+
"y": {"value": bm_y_pixel},
|
|
561
|
+
"text": {"field": bm_cell_name},
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
if bm_transforms:
|
|
565
|
+
bm_cell_layer["transform"] = bm_transforms
|
|
566
|
+
# Label stub: use entry.label when set, else fall back to the field name.
|
|
567
|
+
bm_label_text = entry.label if entry.label is not None else entry.per_series
|
|
568
|
+
if axis_y_orient == "right":
|
|
569
|
+
assert spec_width is not None # pre-check above guarantees this
|
|
570
|
+
bm_label_layer = _label_stub_right_layer(
|
|
571
|
+
row_idx,
|
|
572
|
+
bm_label_text,
|
|
573
|
+
style=style,
|
|
574
|
+
charts_style=charts_style,
|
|
575
|
+
spec_height=spec_height,
|
|
576
|
+
spec_width=spec_width,
|
|
577
|
+
limit=label_limit,
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
bm_label_layer = _label_stub_left_layer(
|
|
581
|
+
row_idx,
|
|
582
|
+
bm_label_text,
|
|
583
|
+
style=style,
|
|
584
|
+
charts_style=charts_style,
|
|
585
|
+
spec_height=spec_height,
|
|
586
|
+
limit=label_limit,
|
|
587
|
+
)
|
|
588
|
+
return [bm_cell_layer, bm_label_layer]
|
|
589
|
+
|
|
590
|
+
# Normal mode: one cell + label per series in series_order.
|
|
591
|
+
# color_field is guaranteed non-None here: attach_data_table raises ValueError
|
|
592
|
+
# before calling this function for normal-mode entries when color_field is None.
|
|
593
|
+
dark_fills: list[str | None]
|
|
594
|
+
if series_palette is not None:
|
|
595
|
+
dark_fills = list(resolve_dark_companion_stops(series_palette))
|
|
596
|
+
else:
|
|
597
|
+
dark_fills = [None] * len(series_order)
|
|
598
|
+
|
|
599
|
+
x_field = parent_x_enc["field"]
|
|
600
|
+
layers: list[dict[str, Any]] = []
|
|
601
|
+
|
|
602
|
+
for series_idx, series_value in enumerate(series_order):
|
|
603
|
+
row_idx = row_start_index + series_idx
|
|
604
|
+
cell_name = f"__data_table_{row_idx}"
|
|
605
|
+
agg_name = f"{cell_name}_val"
|
|
606
|
+
|
|
607
|
+
transforms: list[dict[str, Any]] = [
|
|
608
|
+
{
|
|
609
|
+
"aggregate": [{"op": "sum", "field": entry.per_series, "as": agg_name}],
|
|
610
|
+
"groupby": [x_field, color_field],
|
|
611
|
+
},
|
|
612
|
+
]
|
|
613
|
+
# Filter to this series first, THEN apply period filter, THEN sample.
|
|
614
|
+
# Sampling after the aggregate but before the per-series filter would
|
|
615
|
+
# window over the (x, series) cross-product, where ties on x_field have
|
|
616
|
+
# unspecified row-number order — so series A might keep x1/x3/x5
|
|
617
|
+
# while series B keeps x2/x4/x6, with cells at inconsistent
|
|
618
|
+
# x positions across the strip's rows. Sampling AFTER the
|
|
619
|
+
# per-series filter gives each series an independent 1-row-per-x
|
|
620
|
+
# input that thins consistently. Period filter goes between the
|
|
621
|
+
# per-series filter and sampling: it operates on per-series 1-row-per-x
|
|
622
|
+
# data and further thins to label-period openers.
|
|
623
|
+
safe_val = str(series_value).replace("\\", "\\\\").replace("'", "\\'")
|
|
624
|
+
transforms.append({"filter": f"datum['{color_field}'] === '{safe_val}'"})
|
|
625
|
+
if label_period_filter_expr is not None:
|
|
626
|
+
transforms.append({"filter": label_period_filter_expr})
|
|
627
|
+
if sampling_step > 1:
|
|
628
|
+
transforms.extend(_sampling_transforms(x_field, sampling_step))
|
|
629
|
+
if entry.format is not None:
|
|
630
|
+
transforms.append(
|
|
631
|
+
_vl_format_calc(agg_name, entry.format, cell_name, formats)
|
|
632
|
+
)
|
|
633
|
+
else:
|
|
634
|
+
cell_name = agg_name
|
|
635
|
+
|
|
636
|
+
y_pixel = _row_y_pixel(row_idx, style, charts_style, spec_height)
|
|
637
|
+
cell_layer: dict[str, Any] = {
|
|
638
|
+
"mark": _text_mark_props(style, dx=dx),
|
|
639
|
+
"encoding": {
|
|
640
|
+
"x": _shared_x_encoding(parent_x_enc),
|
|
641
|
+
"y": {"value": y_pixel},
|
|
642
|
+
"text": {"field": cell_name},
|
|
643
|
+
},
|
|
644
|
+
"transform": transforms,
|
|
645
|
+
}
|
|
646
|
+
layers.append(cell_layer)
|
|
647
|
+
|
|
648
|
+
# Label layer — series name with companion-resolved fill ink.
|
|
649
|
+
# Delegates to _label_stub_right_layer / _label_stub_left_layer so
|
|
650
|
+
# the orient-derived geometry (x, align, limit) lives in one place.
|
|
651
|
+
dark_fill = dark_fills[series_idx] if series_idx < len(dark_fills) else None
|
|
652
|
+
if axis_y_orient == "right":
|
|
653
|
+
assert spec_width is not None # pre-loop check guarantees this
|
|
654
|
+
label_layer = _label_stub_right_layer(
|
|
655
|
+
row_idx,
|
|
656
|
+
str(series_value),
|
|
657
|
+
style=style,
|
|
658
|
+
charts_style=charts_style,
|
|
659
|
+
spec_height=spec_height,
|
|
660
|
+
spec_width=spec_width,
|
|
661
|
+
limit=label_limit,
|
|
662
|
+
)
|
|
663
|
+
else:
|
|
664
|
+
label_layer = _label_stub_left_layer(
|
|
665
|
+
row_idx,
|
|
666
|
+
str(series_value),
|
|
667
|
+
style=style,
|
|
668
|
+
charts_style=charts_style,
|
|
669
|
+
spec_height=spec_height,
|
|
670
|
+
limit=label_limit,
|
|
671
|
+
)
|
|
672
|
+
if dark_fill is not None:
|
|
673
|
+
label_layer["mark"]["fill"] = dark_fill
|
|
674
|
+
layers.append(label_layer)
|
|
675
|
+
|
|
676
|
+
return layers
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _divider_rule_layer(
|
|
680
|
+
style: DataTableStyle,
|
|
681
|
+
charts_style: MergedChartsStyle,
|
|
682
|
+
spec_height: float,
|
|
683
|
+
) -> dict[str, Any]:
|
|
684
|
+
"""Strip-top divider rule (ADR-006: rule-only, no header text)."""
|
|
685
|
+
mark: dict[str, Any] = {
|
|
686
|
+
"type": "rule",
|
|
687
|
+
"strokeWidth": style.divider.width,
|
|
688
|
+
}
|
|
689
|
+
if style.divider.color is not None:
|
|
690
|
+
mark["stroke"] = style.divider.color
|
|
691
|
+
return {
|
|
692
|
+
"data": {"values": [{}]},
|
|
693
|
+
"mark": mark,
|
|
694
|
+
"encoding": {
|
|
695
|
+
"y": {"value": _divider_y_pixel(style, charts_style, spec_height)}
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _row_rule_layer(
|
|
701
|
+
index: int,
|
|
702
|
+
style: DataTableStyle,
|
|
703
|
+
charts_style: MergedChartsStyle,
|
|
704
|
+
spec_height: float,
|
|
705
|
+
) -> dict[str, Any]:
|
|
706
|
+
"""Inter-row rule between row `index` and row `index + 1` (spec §4.3)."""
|
|
707
|
+
mark: dict[str, Any] = {
|
|
708
|
+
"type": "rule",
|
|
709
|
+
"strokeWidth": style.row.rule.width,
|
|
710
|
+
}
|
|
711
|
+
if style.row.rule.color is not None:
|
|
712
|
+
mark["stroke"] = style.row.rule.color
|
|
713
|
+
return {
|
|
714
|
+
"data": {"values": [{}]},
|
|
715
|
+
"mark": mark,
|
|
716
|
+
"encoding": {
|
|
717
|
+
"y": {
|
|
718
|
+
"value": _inter_row_rule_y_pixel(
|
|
719
|
+
index, style, charts_style, spec_height
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _label_mark_props(style: DataTableStyle, align: str) -> dict[str, Any]:
|
|
727
|
+
"""Common mark properties shared by left-stub and right-cap label layers.
|
|
728
|
+
|
|
729
|
+
`align` is derived from the chart's resolved axis_y.orient at the call site:
|
|
730
|
+
right-oriented → "left" (text-anchor start); left-oriented → "right" (text-anchor end).
|
|
731
|
+
"""
|
|
732
|
+
mark: dict[str, Any] = {
|
|
733
|
+
"type": "text",
|
|
734
|
+
"align": align,
|
|
735
|
+
"baseline": "middle",
|
|
736
|
+
}
|
|
737
|
+
font = style.label.font
|
|
738
|
+
if font.family is not None:
|
|
739
|
+
mark["font"] = font.family
|
|
740
|
+
if font.size is not None:
|
|
741
|
+
mark["fontSize"] = font.size
|
|
742
|
+
if font.weight is not None:
|
|
743
|
+
mark["fontWeight"] = font.weight
|
|
744
|
+
if font.color is not None:
|
|
745
|
+
mark["fill"] = font.color
|
|
746
|
+
return mark
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _label_stub_left_layer(
|
|
750
|
+
index: int,
|
|
751
|
+
label_text: str,
|
|
752
|
+
style: DataTableStyle,
|
|
753
|
+
charts_style: MergedChartsStyle,
|
|
754
|
+
spec_height: float,
|
|
755
|
+
limit: float | None = None,
|
|
756
|
+
) -> dict[str, Any]:
|
|
757
|
+
"""Left-gutter label layer anchored to the left y-axis tick column.
|
|
758
|
+
|
|
759
|
+
x = -axis_y.label.padding (same column as left-oriented y-axis tick labels,
|
|
760
|
+
which extend leftward from x=0 with text-anchor end / align right).
|
|
761
|
+
mark.limit caps the text width so VL's autosize does not shrink the plot.
|
|
762
|
+
"""
|
|
763
|
+
axis_label_padding = charts_style.axis_y.label.padding
|
|
764
|
+
x_pixel = -axis_label_padding
|
|
765
|
+
mark = _label_mark_props(style, align="right")
|
|
766
|
+
if limit is not None:
|
|
767
|
+
mark["limit"] = limit
|
|
768
|
+
return {
|
|
769
|
+
"data": {"values": [{"__label": label_text}]},
|
|
770
|
+
"mark": mark,
|
|
771
|
+
"encoding": {
|
|
772
|
+
"x": {"value": x_pixel},
|
|
773
|
+
"y": {"value": _row_y_pixel(index, style, charts_style, spec_height)},
|
|
774
|
+
"text": {"field": "__label"},
|
|
775
|
+
# Opt out of inherited color encoding. Without this, VL sees own data
|
|
776
|
+
# that lacks the series field, adds null to the categorical domain, and
|
|
777
|
+
# null sorts first — consuming palette[0] and shifting every series mark
|
|
778
|
+
# one slot up (the dark-companion off-by-one).
|
|
779
|
+
"color": None,
|
|
780
|
+
},
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _label_stub_right_layer(
|
|
785
|
+
index: int,
|
|
786
|
+
label_text: str,
|
|
787
|
+
style: DataTableStyle,
|
|
788
|
+
charts_style: MergedChartsStyle,
|
|
789
|
+
spec_height: float,
|
|
790
|
+
spec_width: float,
|
|
791
|
+
limit: float | None = None,
|
|
792
|
+
) -> dict[str, Any]:
|
|
793
|
+
"""Right-cap label layer anchored to the right y-axis tick column.
|
|
794
|
+
|
|
795
|
+
x = spec_width + axis_y.label.padding (same column as right-oriented y-axis
|
|
796
|
+
tick labels, which extend rightward from that anchor with text-anchor start /
|
|
797
|
+
align left). mark.limit caps the text width so VL's autosize does not shrink
|
|
798
|
+
the plot.
|
|
799
|
+
"""
|
|
800
|
+
axis_label_padding = charts_style.axis_y.label.padding
|
|
801
|
+
x_pixel = spec_width + axis_label_padding
|
|
802
|
+
mark = _label_mark_props(style, align="left")
|
|
803
|
+
if limit is not None:
|
|
804
|
+
mark["limit"] = limit
|
|
805
|
+
return {
|
|
806
|
+
"data": {"values": [{"__label": label_text}]},
|
|
807
|
+
"mark": mark,
|
|
808
|
+
"encoding": {
|
|
809
|
+
"x": {"value": x_pixel},
|
|
810
|
+
"y": {"value": _row_y_pixel(index, style, charts_style, spec_height)},
|
|
811
|
+
"text": {"field": "__label"},
|
|
812
|
+
# Opt out of inherited color encoding — same fix as _label_stub_left_layer.
|
|
813
|
+
"color": None,
|
|
814
|
+
},
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def validate_data_table_against_data(
|
|
819
|
+
data_table: ChartDataTable,
|
|
820
|
+
x_field: str | None,
|
|
821
|
+
data: list[dict[str, Any]],
|
|
822
|
+
x_type: str | None = None,
|
|
823
|
+
) -> int:
|
|
824
|
+
"""Data-aware validation of a data_table block (spec §3.1, §3.2).
|
|
825
|
+
|
|
826
|
+
Runs at render time where the actual query output is available.
|
|
827
|
+
Complements the data-free checks in AuthoredChart.validate_data_table
|
|
828
|
+
(duplicate entries, chart-type, multi-y).
|
|
829
|
+
|
|
830
|
+
For temporal/quantitative x axes with more than CHART_DATA_TABLE_MAX_X_TICKS
|
|
831
|
+
rows, sampling is applied instead of failing. Returns the sampling step
|
|
832
|
+
(>= 1); step=1 means no sampling is needed.
|
|
833
|
+
|
|
834
|
+
For ordinal/nominal/unknown x_type exceeding the cap, raises ValueError
|
|
835
|
+
(fail-closed: dropping categories silently would be wrong).
|
|
836
|
+
|
|
837
|
+
Raises:
|
|
838
|
+
ValueError on violation. Messages are explicit — they name the
|
|
839
|
+
offending field and point at the resolution (e.g., "Add
|
|
840
|
+
'aggregate: <op>'...").
|
|
841
|
+
"""
|
|
842
|
+
# Spec §3.1 — x-axis must exist.
|
|
843
|
+
if not x_field:
|
|
844
|
+
raise ValueError(
|
|
845
|
+
"chart.data_table requires an x-encoding; the chart has no "
|
|
846
|
+
"`x:` field. Add `x: <column>` or remove the data_table block."
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
# Spec §3.1 — x-axis cardinality cap.
|
|
850
|
+
x_values = {row.get(x_field) for row in data if x_field in row}
|
|
851
|
+
n = len(x_values)
|
|
852
|
+
sampling_step = 1
|
|
853
|
+
if n > CHART_DATA_TABLE_MAX_X_TICKS:
|
|
854
|
+
if x_type in ("temporal", "quantitative"):
|
|
855
|
+
# Temporal/quantitative: thin the strip labels via Vega-Lite
|
|
856
|
+
# window+filter transforms. The chart data layers are unaffected.
|
|
857
|
+
# Continue validating §3.2 below — the early-return pattern would
|
|
858
|
+
# silently skip source-column existence and G1 multi-row checks.
|
|
859
|
+
sampling_step = math.ceil(n / CHART_DATA_TABLE_MAX_X_TICKS)
|
|
860
|
+
else:
|
|
861
|
+
raise ValueError(
|
|
862
|
+
f"chart.data_table supports at most "
|
|
863
|
+
f"{CHART_DATA_TABLE_MAX_X_TICKS} x-axis ticks; got "
|
|
864
|
+
f"{n}. Aggregate or filter in the query before "
|
|
865
|
+
"rendering an attached table."
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
# Spec §3.2 — every referenced column must be present in the query output.
|
|
869
|
+
# Use the first row as the schema sample (mirrors how the rest of the
|
|
870
|
+
# render pipeline reads row shape).
|
|
871
|
+
if data:
|
|
872
|
+
available = set(data[0].keys())
|
|
873
|
+
for entry in data_table.entries:
|
|
874
|
+
if isinstance(entry, ChartDataTablePerSeries):
|
|
875
|
+
source_col = entry.per_series
|
|
876
|
+
else:
|
|
877
|
+
source_col = entry.source
|
|
878
|
+
if source_col not in available:
|
|
879
|
+
cols = ", ".join(sorted(available))
|
|
880
|
+
raise ValueError(
|
|
881
|
+
f"chart.data_table entry references source column "
|
|
882
|
+
f"{source_col!r} which is not in the query output. "
|
|
883
|
+
f"Available columns: {cols}."
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Spec §3.2 G1 — a bare `source:` entry is ambiguous if the query
|
|
887
|
+
# returns multiple rows per x. Point the author at `aggregate:`.
|
|
888
|
+
# Normal per_series entries (by_measure=False) are exempt — they aggregate
|
|
889
|
+
# groupby [x, color] and expect multiple rows per x.
|
|
890
|
+
# by_measure entries are NOT exempt — they read datum[field] directly with
|
|
891
|
+
# no aggregation transform, so multiple rows per x produce a wrong result.
|
|
892
|
+
if data and x_field:
|
|
893
|
+
from collections import Counter
|
|
894
|
+
|
|
895
|
+
counts = Counter(row.get(x_field) for row in data)
|
|
896
|
+
multi_row = any(c > 1 for c in counts.values())
|
|
897
|
+
if multi_row:
|
|
898
|
+
for entry in data_table.entries:
|
|
899
|
+
is_aggregate = isinstance(entry, ChartDataTableAggregate)
|
|
900
|
+
is_normal_per_series = (
|
|
901
|
+
isinstance(entry, ChartDataTablePerSeries) and not entry.by_measure
|
|
902
|
+
)
|
|
903
|
+
if not is_aggregate and not is_normal_per_series:
|
|
904
|
+
source_col = (
|
|
905
|
+
entry.per_series
|
|
906
|
+
if isinstance(entry, ChartDataTablePerSeries)
|
|
907
|
+
else entry.source
|
|
908
|
+
)
|
|
909
|
+
raise ValueError(
|
|
910
|
+
f"chart.data_table entry references column {source_col!r} "
|
|
911
|
+
f"which is ambiguous on this chart — {x_field!r} "
|
|
912
|
+
"returns multiple rows per x. Add "
|
|
913
|
+
f"'aggregate: <op>' (e.g. 'aggregate: sum, "
|
|
914
|
+
f"source: {source_col}') to resolve."
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
return sampling_step
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _sampling_transforms(x_field: str, step: int) -> list[dict[str, Any]]:
|
|
921
|
+
"""Vega-Lite window + filter transforms for strip-cell sampling.
|
|
922
|
+
|
|
923
|
+
Assigns a 1-indexed row_number sorted ascending by x_field, then filters
|
|
924
|
+
to rows where (row_number - 1) % step == 0, keeping the first row and
|
|
925
|
+
every step-th row after it.
|
|
926
|
+
"""
|
|
927
|
+
return [
|
|
928
|
+
{
|
|
929
|
+
"window": [{"op": "row_number", "as": "__data_table_row_index"}],
|
|
930
|
+
"sort": [{"field": x_field, "order": "ascending"}],
|
|
931
|
+
},
|
|
932
|
+
{"filter": f"(datum.__data_table_row_index - 1) % {step} === 0"},
|
|
933
|
+
]
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def resolve_effective_data_table_style(
|
|
937
|
+
charts_style: MergedChartsStyle, chart_type: str
|
|
938
|
+
) -> DataTableStyle:
|
|
939
|
+
"""Resolve the effective data_table style for a chart.
|
|
940
|
+
|
|
941
|
+
Applies the per-chart-type override (tier 2, spec §4.1) on top of the
|
|
942
|
+
universal style.charts.data_table (tier 1). Tier 3 (chart-local patch)
|
|
943
|
+
is already merged into charts_style.data_table via pipeline.py before
|
|
944
|
+
this function is called.
|
|
945
|
+
"""
|
|
946
|
+
base: DataTableStyle = charts_style.data_table
|
|
947
|
+
per_type = getattr(charts_style, chart_type, None)
|
|
948
|
+
if per_type is None:
|
|
949
|
+
return base
|
|
950
|
+
override = getattr(per_type, "data_table", None)
|
|
951
|
+
if override is None:
|
|
952
|
+
return base
|
|
953
|
+
return deep_merge(base, override) # type: ignore[return-value]
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def attach_data_table(
|
|
957
|
+
spec: dict[str, Any],
|
|
958
|
+
*,
|
|
959
|
+
data_table: ChartDataTable | None,
|
|
960
|
+
style: DataTableStyle,
|
|
961
|
+
charts_style: MergedChartsStyle | None = None,
|
|
962
|
+
sampling_step: int = 1,
|
|
963
|
+
entry_dx: list[float] | None = None,
|
|
964
|
+
series_order: list[str] | None = None,
|
|
965
|
+
series_palette: list[str] | None = None,
|
|
966
|
+
label_period_filter_expr: str | None = None,
|
|
967
|
+
axis_y_orient: Literal["left", "right"],
|
|
968
|
+
formats: dict[str, str] | None = None,
|
|
969
|
+
) -> dict[str, Any]:
|
|
970
|
+
"""Append attached-data-table layers to a Vega-Lite chart spec.
|
|
971
|
+
|
|
972
|
+
Y-positioning is pixel-literal (`{"y": {"value": <px>}}`), anchored to
|
|
973
|
+
the spec's explicit height. Vega-Lite scales `{"y": {"expr": ...}}`
|
|
974
|
+
through the parent's y-scale rather than treating it as a pixel
|
|
975
|
+
literal, so the spec must carry an explicit height (Dataface's
|
|
976
|
+
standard renderer always sets one).
|
|
977
|
+
|
|
978
|
+
Args:
|
|
979
|
+
spec: Base Vega-Lite chart spec (mark + encoding, or already layered).
|
|
980
|
+
data_table: Parsed ChartDataTable block, or None for no-op.
|
|
981
|
+
style: Resolved DataTableStyle (already cascade-resolved).
|
|
982
|
+
charts_style: Resolved ChartsStyle for x-axis label offset computation.
|
|
983
|
+
Required when data_table is non-None.
|
|
984
|
+
sampling_step: When > 1, prepend a Vega-Lite window+filter transform
|
|
985
|
+
chain to each text-mark layer so only every Nth row renders a
|
|
986
|
+
cell. The chart's bar/line/area base layer is not affected.
|
|
987
|
+
Computed by validate_data_table_against_data for temporal/
|
|
988
|
+
quantitative x axes that exceed CHART_DATA_TABLE_MAX_X_TICKS.
|
|
989
|
+
entry_dx: Per-entry dx offsets (pixels) for band-centering. When
|
|
990
|
+
set, entry_dx[i] is applied as VL mark ``dx`` on the text layer
|
|
991
|
+
for entry i, shifting right-aligned text so the number lane's
|
|
992
|
+
centre aligns with the band midpoint (same invariant as
|
|
993
|
+
_compute_lane_positions in table.py: column centred, decimals
|
|
994
|
+
aligned within). Computed by the render layer from measured
|
|
995
|
+
max formatted widths; None means no centering (e.g. widths all zero).
|
|
996
|
+
series_order: For per_series entries — ordered list of series names
|
|
997
|
+
in the chart's stack/scale order. Required when any entry is a
|
|
998
|
+
ChartDataTablePerSeries. Callers resolve this from the parent
|
|
999
|
+
chart's color encoding domain.
|
|
1000
|
+
series_palette: For per_series entries — the effective palette colours
|
|
1001
|
+
for each series in series_order, used to resolve dark companion
|
|
1002
|
+
label ink. When None, label fill is omitted.
|
|
1003
|
+
label_period_filter_expr: Vega filter expression that restricts strip
|
|
1004
|
+
cells to label-period openers. When set, each text-mark layer
|
|
1005
|
+
receives a ``{"filter": label_period_filter_expr}`` transform so
|
|
1006
|
+
only x-values that open a new label period (e.g. quarterly openers
|
|
1007
|
+
on a monthly-band axis) emit a cell. Computed by the render layer
|
|
1008
|
+
from the chart's encoding_time_unit / label_time_unit pair.
|
|
1009
|
+
axis_y_orient: Resolved y-axis orient — ``"right"`` or ``"left"``
|
|
1010
|
+
(line charts with right-edge endpoint labels). Determines which label
|
|
1011
|
+
emitter fires: right-oriented → label at ``spec_width +
|
|
1012
|
+
axis_y.label.padding`` with ``align: left``; left-oriented → label at
|
|
1013
|
+
``-axis_y.label.padding`` with ``align: right``. Required — callers must
|
|
1014
|
+
resolve this from the chart's axis_y.orient before calling.
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
New spec dict with attached layers appended and ``autosize`` set
|
|
1018
|
+
to ``pad`` so the outer SVG expands to include the strip rather
|
|
1019
|
+
than shrinking the plot. Padding is NOT touched — callers must
|
|
1020
|
+
reserve the strip's height themselves via
|
|
1021
|
+
``data_table_strip_height``. Input spec not mutated.
|
|
1022
|
+
"""
|
|
1023
|
+
if data_table is None or not data_table.entries:
|
|
1024
|
+
return spec
|
|
1025
|
+
|
|
1026
|
+
if charts_style is None:
|
|
1027
|
+
raise ValueError(
|
|
1028
|
+
"attach_data_table requires charts_style when data_table is "
|
|
1029
|
+
"non-None — x-axis label offset computation needs it."
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
spec_height = spec.get("height")
|
|
1033
|
+
if not isinstance(spec_height, (int, float)) or spec_height <= 0:
|
|
1034
|
+
raise ValueError(
|
|
1035
|
+
"attach_data_table requires the spec to carry an explicit height "
|
|
1036
|
+
"when data_table is non-None — pixel-literal strip positioning "
|
|
1037
|
+
"has no anchor otherwise."
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
raw_width = spec.get("width")
|
|
1041
|
+
spec_width = (
|
|
1042
|
+
float(raw_width)
|
|
1043
|
+
if isinstance(raw_width, (int, float)) and raw_width > 0
|
|
1044
|
+
else None
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
# Compute the total number of visual rows.
|
|
1048
|
+
# by_measure entries each expand to 1 row; normal per_series entries expand
|
|
1049
|
+
# to N rows (one per series); source/aggregate entries are 1 row each.
|
|
1050
|
+
n_visual_rows = 0
|
|
1051
|
+
for entry in data_table.entries:
|
|
1052
|
+
if isinstance(entry, ChartDataTablePerSeries):
|
|
1053
|
+
if entry.by_measure:
|
|
1054
|
+
n_visual_rows += 1
|
|
1055
|
+
else:
|
|
1056
|
+
n_visual_rows += len(series_order) if series_order else 1
|
|
1057
|
+
else:
|
|
1058
|
+
n_visual_rows += 1
|
|
1059
|
+
|
|
1060
|
+
# Extract parent x-encoding before wrapping (wrap moves encoding into the base layer).
|
|
1061
|
+
#
|
|
1062
|
+
# Three shapes are accepted:
|
|
1063
|
+
#
|
|
1064
|
+
# 1. Standard top-level encoding — `spec["encoding"]["x"]` is set. The bulk
|
|
1065
|
+
# of charts arrive here.
|
|
1066
|
+
# 2. Layered VL spec with x per-layer only (no top-level encoding.x) — the
|
|
1067
|
+
# migrator's `type: layered` emission with multiple bar layers + chart-level
|
|
1068
|
+
# x + data_table renders into this shape: top-level `spec["encoding"]` is
|
|
1069
|
+
# empty/absent and every `spec["layer"][i]["encoding"]` carries the same
|
|
1070
|
+
# x. We anchor against `layer[0]`'s x.
|
|
1071
|
+
# 3. Any other shape — raise ChartDataError. The strip needs an unambiguous
|
|
1072
|
+
# anchor; silently picking layer-0's x when other layers disagree (or when
|
|
1073
|
+
# resolve.scale.x is independent) would misalign the strip against the
|
|
1074
|
+
# other layers' x-buckets, which is the "wrong result that looks right"
|
|
1075
|
+
# failure mode the repo non-negotiables forbid.
|
|
1076
|
+
_raw_top_encoding = spec.get("encoding")
|
|
1077
|
+
top_encoding: dict[str, Any] = (
|
|
1078
|
+
_raw_top_encoding if isinstance(_raw_top_encoding, dict) else {}
|
|
1079
|
+
)
|
|
1080
|
+
if "x" in top_encoding:
|
|
1081
|
+
parent_x_enc = top_encoding["x"]
|
|
1082
|
+
else:
|
|
1083
|
+
from dataface.core.render.errors import ChartDataError
|
|
1084
|
+
|
|
1085
|
+
layers_inline = spec.get("layer")
|
|
1086
|
+
if not isinstance(layers_inline, list) or not layers_inline:
|
|
1087
|
+
raise ChartDataError(
|
|
1088
|
+
"data_table attachment requires an x-encoding on the chart spec "
|
|
1089
|
+
"or its layers; none found."
|
|
1090
|
+
)
|
|
1091
|
+
if spec.get("resolve", {}).get("scale", {}).get("x") == "independent":
|
|
1092
|
+
raise ChartDataError(
|
|
1093
|
+
"data_table strip cannot anchor to a layered spec with "
|
|
1094
|
+
"resolve.scale.x == 'independent'; the layers have per-dataset "
|
|
1095
|
+
"x scales and the strip cannot anchor coherently."
|
|
1096
|
+
)
|
|
1097
|
+
layer_x_encs = [
|
|
1098
|
+
layer.get("encoding", {}).get("x")
|
|
1099
|
+
for layer in layers_inline
|
|
1100
|
+
if isinstance(layer, dict)
|
|
1101
|
+
]
|
|
1102
|
+
non_null_x_encs = [enc for enc in layer_x_encs if isinstance(enc, dict)]
|
|
1103
|
+
if not non_null_x_encs:
|
|
1104
|
+
raise ChartDataError(
|
|
1105
|
+
"data_table attachment requires an x-encoding on the chart spec "
|
|
1106
|
+
"or its layers; none found."
|
|
1107
|
+
)
|
|
1108
|
+
first_x_field = non_null_x_encs[0].get("field")
|
|
1109
|
+
for enc in non_null_x_encs[1:]:
|
|
1110
|
+
if enc.get("field") != first_x_field:
|
|
1111
|
+
raise ChartDataError(
|
|
1112
|
+
"data_table strip cannot anchor to a layered spec whose "
|
|
1113
|
+
"per-layer x-encodings disagree on field; lift x to chart "
|
|
1114
|
+
"level or remove data_table."
|
|
1115
|
+
)
|
|
1116
|
+
parent_x_enc = non_null_x_encs[0]
|
|
1117
|
+
# Resolve the color field from the parent spec's top-level encoding for
|
|
1118
|
+
# per_series entries. In the layered fallback case top-level encoding has
|
|
1119
|
+
# no color (color lives per-layer); return None and let the per_series
|
|
1120
|
+
# caller require an explicit series_order/series_palette.
|
|
1121
|
+
parent_color_enc = top_encoding.get("color")
|
|
1122
|
+
color_field: str | None = None
|
|
1123
|
+
if isinstance(parent_color_enc, dict):
|
|
1124
|
+
color_field = parent_color_enc.get("field")
|
|
1125
|
+
|
|
1126
|
+
out = _wrap_base_as_layer(spec)
|
|
1127
|
+
layers: list[dict[str, Any]] = list(out["layer"])
|
|
1128
|
+
|
|
1129
|
+
if style.divider.width > 0:
|
|
1130
|
+
layers.append(_divider_rule_layer(style, charts_style, spec_height))
|
|
1131
|
+
|
|
1132
|
+
_label_stub_limit: float = _LABEL_STUB_LIMIT_PX
|
|
1133
|
+
|
|
1134
|
+
# Track the current visual row index as we iterate entries. Source and
|
|
1135
|
+
# aggregate entries each consume 1 row; per_series entries consume N rows.
|
|
1136
|
+
visual_row_idx = 0
|
|
1137
|
+
for i, entry in enumerate(data_table.entries):
|
|
1138
|
+
row_dx = entry_dx[i] if entry_dx is not None else None
|
|
1139
|
+
if isinstance(entry, ChartDataTablePerSeries):
|
|
1140
|
+
if entry.by_measure:
|
|
1141
|
+
# by_measure: one row per entry, no series expansion needed.
|
|
1142
|
+
bm_layers = _per_series_row_layers(
|
|
1143
|
+
visual_row_idx,
|
|
1144
|
+
entry,
|
|
1145
|
+
parent_x_enc=parent_x_enc,
|
|
1146
|
+
color_field=None,
|
|
1147
|
+
series_order=[],
|
|
1148
|
+
series_palette=None,
|
|
1149
|
+
style=style,
|
|
1150
|
+
charts_style=charts_style,
|
|
1151
|
+
spec_height=spec_height,
|
|
1152
|
+
spec_width=spec_width,
|
|
1153
|
+
sampling_step=sampling_step,
|
|
1154
|
+
dx=row_dx,
|
|
1155
|
+
label_period_filter_expr=label_period_filter_expr,
|
|
1156
|
+
axis_y_orient=axis_y_orient,
|
|
1157
|
+
label_limit=_label_stub_limit,
|
|
1158
|
+
)
|
|
1159
|
+
layers.extend(bm_layers)
|
|
1160
|
+
if style.row.rule.width > 0 and visual_row_idx < n_visual_rows - 1:
|
|
1161
|
+
layers.append(
|
|
1162
|
+
_row_rule_layer(
|
|
1163
|
+
visual_row_idx, style, charts_style, spec_height
|
|
1164
|
+
)
|
|
1165
|
+
)
|
|
1166
|
+
visual_row_idx += 1
|
|
1167
|
+
else:
|
|
1168
|
+
if not series_order:
|
|
1169
|
+
raise ValueError(
|
|
1170
|
+
"attach_data_table requires series_order when per_series "
|
|
1171
|
+
"entries are present — callers must supply the ordered series "
|
|
1172
|
+
"list resolved from the parent chart's color encoding domain."
|
|
1173
|
+
)
|
|
1174
|
+
if color_field is None:
|
|
1175
|
+
raise ValueError(
|
|
1176
|
+
"attach_data_table requires the spec to carry a color "
|
|
1177
|
+
"encoding when per_series entries are present."
|
|
1178
|
+
)
|
|
1179
|
+
per_series_layers = _per_series_row_layers(
|
|
1180
|
+
visual_row_idx,
|
|
1181
|
+
entry,
|
|
1182
|
+
parent_x_enc=parent_x_enc,
|
|
1183
|
+
color_field=color_field,
|
|
1184
|
+
series_order=series_order,
|
|
1185
|
+
series_palette=series_palette,
|
|
1186
|
+
style=style,
|
|
1187
|
+
charts_style=charts_style,
|
|
1188
|
+
spec_height=spec_height,
|
|
1189
|
+
spec_width=spec_width,
|
|
1190
|
+
sampling_step=sampling_step,
|
|
1191
|
+
dx=row_dx,
|
|
1192
|
+
label_period_filter_expr=label_period_filter_expr,
|
|
1193
|
+
axis_y_orient=axis_y_orient,
|
|
1194
|
+
label_limit=_label_stub_limit,
|
|
1195
|
+
formats=formats,
|
|
1196
|
+
)
|
|
1197
|
+
layers.extend(per_series_layers)
|
|
1198
|
+
n_series = len(series_order)
|
|
1199
|
+
# Inter-row rules: emit ONE per pair of adjacent visual rows
|
|
1200
|
+
# within the per_series block (n_series - 1 rules) plus one
|
|
1201
|
+
# trailing rule between this block and the next entry — same
|
|
1202
|
+
# cadence as source/aggregate rows, where every adjacent visual
|
|
1203
|
+
# pair gets a rule. Without the within-block rules, the strip
|
|
1204
|
+
# height accounting (which includes inter-row spacing for every
|
|
1205
|
+
# visual row) reserves gaps where no rules are drawn.
|
|
1206
|
+
if style.row.rule.width > 0:
|
|
1207
|
+
last_in_block = visual_row_idx + n_series - 1
|
|
1208
|
+
# Within-block rules: between each pair of adjacent series.
|
|
1209
|
+
for inner_idx in range(visual_row_idx, last_in_block):
|
|
1210
|
+
layers.append(
|
|
1211
|
+
_row_rule_layer(inner_idx, style, charts_style, spec_height)
|
|
1212
|
+
)
|
|
1213
|
+
# Trailing rule: between this block and the next entry.
|
|
1214
|
+
if last_in_block < n_visual_rows - 1:
|
|
1215
|
+
layers.append(
|
|
1216
|
+
_row_rule_layer(
|
|
1217
|
+
last_in_block, style, charts_style, spec_height
|
|
1218
|
+
)
|
|
1219
|
+
)
|
|
1220
|
+
visual_row_idx += n_series
|
|
1221
|
+
else:
|
|
1222
|
+
row_layer = _row_text_layer(
|
|
1223
|
+
visual_row_idx,
|
|
1224
|
+
entry,
|
|
1225
|
+
parent_x_enc=parent_x_enc,
|
|
1226
|
+
style=style,
|
|
1227
|
+
charts_style=charts_style,
|
|
1228
|
+
spec_height=spec_height,
|
|
1229
|
+
sampling_step=sampling_step,
|
|
1230
|
+
dx=row_dx,
|
|
1231
|
+
label_period_filter_expr=label_period_filter_expr,
|
|
1232
|
+
formats=formats,
|
|
1233
|
+
)
|
|
1234
|
+
layers.append(row_layer)
|
|
1235
|
+
label = getattr(entry, "label", None)
|
|
1236
|
+
if label:
|
|
1237
|
+
if axis_y_orient == "right":
|
|
1238
|
+
if spec_width is None:
|
|
1239
|
+
raise ValueError(
|
|
1240
|
+
"attach_data_table requires the spec to carry an explicit "
|
|
1241
|
+
"width when right-cap labels are present — pixel-literal "
|
|
1242
|
+
"label positioning has no anchor otherwise."
|
|
1243
|
+
)
|
|
1244
|
+
layers.append(
|
|
1245
|
+
_label_stub_right_layer(
|
|
1246
|
+
visual_row_idx,
|
|
1247
|
+
label,
|
|
1248
|
+
style=style,
|
|
1249
|
+
charts_style=charts_style,
|
|
1250
|
+
spec_height=spec_height,
|
|
1251
|
+
spec_width=spec_width,
|
|
1252
|
+
limit=_label_stub_limit,
|
|
1253
|
+
)
|
|
1254
|
+
)
|
|
1255
|
+
else:
|
|
1256
|
+
layers.append(
|
|
1257
|
+
_label_stub_left_layer(
|
|
1258
|
+
visual_row_idx,
|
|
1259
|
+
label,
|
|
1260
|
+
style=style,
|
|
1261
|
+
charts_style=charts_style,
|
|
1262
|
+
spec_height=spec_height,
|
|
1263
|
+
limit=_label_stub_limit,
|
|
1264
|
+
)
|
|
1265
|
+
)
|
|
1266
|
+
if style.row.rule.width > 0 and visual_row_idx < n_visual_rows - 1:
|
|
1267
|
+
layers.append(
|
|
1268
|
+
_row_rule_layer(visual_row_idx, style, charts_style, spec_height)
|
|
1269
|
+
)
|
|
1270
|
+
visual_row_idx += 1
|
|
1271
|
+
|
|
1272
|
+
out["layer"] = layers
|
|
1273
|
+
# Strip rows live outside the plot area (pixel y > spec.height for bottom;
|
|
1274
|
+
# pixel y < 0 for top). Override the chart-wide `autosize: fit` default with
|
|
1275
|
+
# `pad` so the outer SVG grows to include the strip rather than shrinking the plot. Trade-off: under
|
|
1276
|
+
# `pad`, spec.width refers to the inner plot, so the outer SVG is wider
|
|
1277
|
+
# than the requested width by axis-y label + padding overhead (constant
|
|
1278
|
+
# regardless of requested width — see
|
|
1279
|
+
# test_render_pipeline_svg_width_overhead_independent_of_requested_width).
|
|
1280
|
+
# layout_sizing.py compensates symmetrically on both axes. The initial
|
|
1281
|
+
# render-first sizing pass (_make_data_aware_height_provider) renders once
|
|
1282
|
+
# to measure the width overhead, then re-renders with spec.width pre-shrunk.
|
|
1283
|
+
# Cols alignment (_align_cols_heights) has a hard target_height, so after
|
|
1284
|
+
# its alignment re-render it measures any height overhead and re-renders
|
|
1285
|
+
# with spec.height pre-shrunk too. Both axes fit the slot.
|
|
1286
|
+
out["autosize"] = {"type": "pad", "contains": "padding"}
|
|
1287
|
+
return out
|