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,1452 @@
|
|
|
1
|
+
"""Shared table layout and style helpers for SVG rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterator, Sequence
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
9
|
+
|
|
10
|
+
from dataface.core.compile.models.chart.authored import (
|
|
11
|
+
coerce_numeric,
|
|
12
|
+
match_predicate,
|
|
13
|
+
)
|
|
14
|
+
from dataface.core.compile.models.primitives import FontStyle
|
|
15
|
+
from dataface.core.compile.models.style.compiled import (
|
|
16
|
+
TableChartStylePatch,
|
|
17
|
+
font_weight_as_css,
|
|
18
|
+
)
|
|
19
|
+
from dataface.core.compile.palette import palette as _resolve_named_palette
|
|
20
|
+
from dataface.core.render.board_links import get_link_context, resolve_href
|
|
21
|
+
from dataface.core.render.chart.title_overflow import (
|
|
22
|
+
TitleOverflowMode,
|
|
23
|
+
prepare_title_text,
|
|
24
|
+
resolve_title_overflow,
|
|
25
|
+
)
|
|
26
|
+
from dataface.core.render.format_utils import format_value, resolve_format
|
|
27
|
+
from dataface.core.render.text.case import CaseValue, apply_case
|
|
28
|
+
from dataface.core.render.utils import slug_to_text
|
|
29
|
+
|
|
30
|
+
# Three-lane numeric rendering constants used by table.py:_compute_lane_positions.
|
|
31
|
+
_WORD_SUFFIXES: frozenset[str] = frozenset({"K", "M", "B", "T", "k", "mn", "bn", "tr"})
|
|
32
|
+
|
|
33
|
+
# Swatch column geometry. The rect is 14px square (table.py _SWATCH_SIZE);
|
|
34
|
+
# _SWATCH_CELL_DEMAND adds 4px breathing room on each side so a swatch column's
|
|
35
|
+
# demand/floor reflects the painted rect, not the hex-color string the cell
|
|
36
|
+
# carries. Lives here (not in table.py) so the measurement helpers below can
|
|
37
|
+
# read it without a circular import.
|
|
38
|
+
_SWATCH_CELL_DEMAND = 18 # _SWATCH_SIZE (14) + 4px breathing room
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from dataface.core.compile.models.chart.authored import (
|
|
43
|
+
ConditionalRule,
|
|
44
|
+
ScaleTargetConfig,
|
|
45
|
+
TableColumnConfig,
|
|
46
|
+
)
|
|
47
|
+
from dataface.core.compile.models.primitives import FormatConfig
|
|
48
|
+
from dataface.core.compile.models.style.compiled import TableColumnDefaultsConfig
|
|
49
|
+
|
|
50
|
+
_TEMPLATE_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
|
51
|
+
|
|
52
|
+
# Date pattern used by is_date_like (cell scope, re-exported into table.py).
|
|
53
|
+
_DATE_RE = re.compile(
|
|
54
|
+
r"^\d{4}-\d{2}(-\d{2})?$" # ISO: 2024-03 or 2024-03-15
|
|
55
|
+
r"|^\d{2}/\d{2}/\d{4}$" # US: 03/15/2024
|
|
56
|
+
r"|^Q[1-4]\s+\d{4}$" # Quarter: Q1 2024
|
|
57
|
+
r"|^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}$"
|
|
58
|
+
r"|^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s+\d{4}$"
|
|
59
|
+
r"|^\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4}$"
|
|
60
|
+
r"|^\d{4}$" # Year: 2024
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_date_like(value: Any) -> bool:
|
|
65
|
+
"""Return True for date-like values: Python date/datetime objects or
|
|
66
|
+
string values matching date patterns.
|
|
67
|
+
|
|
68
|
+
Recognized string patterns: ISO (2024-03, 2024-03-15), US (03/15/2024),
|
|
69
|
+
short month name (Mar 15), long form (Mar 15, 2024), euro
|
|
70
|
+
(15 Mar 2024), quarter (Q1 2024), year (2024).
|
|
71
|
+
|
|
72
|
+
Accepts datetime.date / datetime.datetime directly so that layout
|
|
73
|
+
decisions (right-align, tabular font, no-wrap) apply to native Python
|
|
74
|
+
temporal objects returned by DuckDB fetchall(), not just pre-formatted
|
|
75
|
+
strings.
|
|
76
|
+
|
|
77
|
+
Cell-scope date detector used by the table renderer.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(value, bool):
|
|
80
|
+
return False
|
|
81
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
82
|
+
return True
|
|
83
|
+
if not isinstance(value, str) or not value:
|
|
84
|
+
return False
|
|
85
|
+
return bool(_DATE_RE.match(value.strip()))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Matches ISO date "YYYY-MM-DD", ISO datetime "YYYY-MM-DDTHH:MM:SS[...]",
|
|
89
|
+
# and Postgres-style space-separated timestamps "YYYY-MM-DD HH:MM:SS[...]".
|
|
90
|
+
# Does NOT match bare year strings like "2024" (those are numeric).
|
|
91
|
+
_ISO_TEMPORAL_RE = re.compile(
|
|
92
|
+
r"^\d{4}-\d{2}-\d{2}" # date part: YYYY-MM-DD
|
|
93
|
+
r"([T ]\d{2}:\d{2}:\d{2}(\.\d+)?" # optional time (T or space separator)
|
|
94
|
+
r"(Z|[+-]\d{2}:?\d{2})?)?$" # optional TZ suffix
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Valid strftime/d3-time-format directive letters. Padding modifiers (-, _, 0)
|
|
98
|
+
# between % and the letter are allowed (e.g. %-d, %_d, %0H).
|
|
99
|
+
# %% is a literal-percent escape and is validated separately.
|
|
100
|
+
_VALID_STRFTIME_DIRECTIVES: frozenset[str] = frozenset(
|
|
101
|
+
"aAbBcCdDeEfFgGhHIjklmMnOpPqrRsSTuUVwWxXyYzZ"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Matches a % directive sequence: optional padding modifier + letter.
|
|
105
|
+
_STRFTIME_DIRECTIVE_RE = re.compile(r"%[-_0]?(.)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_temporal_value(value: Any) -> bool:
|
|
109
|
+
"""Return True for Python date/datetime objects and ISO temporal strings.
|
|
110
|
+
|
|
111
|
+
Covers:
|
|
112
|
+
- datetime.date and datetime.datetime (including timezone-aware)
|
|
113
|
+
- ISO strings "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SS[...]"
|
|
114
|
+
- Postgres-style space-separated timestamps "YYYY-MM-DD HH:MM:SS[...]"
|
|
115
|
+
|
|
116
|
+
Does NOT match bare year strings like "2024" (those are numeric).
|
|
117
|
+
TIME/INTERVAL values from DuckDB pass through str() — they are not
|
|
118
|
+
date-like in the calendar sense and strftime would crash on them.
|
|
119
|
+
|
|
120
|
+
Applies post-regex calendar validation so "2024-13-99" (regex-matching
|
|
121
|
+
but logically invalid) returns False rather than causing a ValueError
|
|
122
|
+
at format time.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(value, bool):
|
|
125
|
+
return False
|
|
126
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
127
|
+
return True
|
|
128
|
+
if not isinstance(value, str) or len(value) < 10:
|
|
129
|
+
return False
|
|
130
|
+
stripped = value.strip()
|
|
131
|
+
if not _ISO_TEMPORAL_RE.match(stripped):
|
|
132
|
+
return False
|
|
133
|
+
# Post-regex: validate the date part is a real calendar date.
|
|
134
|
+
date_part = stripped[:10]
|
|
135
|
+
try:
|
|
136
|
+
datetime.date.fromisoformat(date_part)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return False
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _validate_strftime_spec(format_spec: str) -> None:
|
|
143
|
+
"""Raise ValueError for format specs containing unknown % directives.
|
|
144
|
+
|
|
145
|
+
Python's strftime silently passes unknown directives through (e.g. %Q → Q).
|
|
146
|
+
We reject them explicitly so authors get a clear error rather than garbled output.
|
|
147
|
+
|
|
148
|
+
``%%`` (literal percent escape) is accepted and stripped before validation.
|
|
149
|
+
"""
|
|
150
|
+
stripped = format_spec.replace("%%", "")
|
|
151
|
+
for m in _STRFTIME_DIRECTIVE_RE.finditer(stripped):
|
|
152
|
+
letter = m.group(1)
|
|
153
|
+
if letter not in _VALID_STRFTIME_DIRECTIVES:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"Invalid temporal format spec {format_spec!r}: "
|
|
156
|
+
f"unknown directive %{letter!r}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _format_temporal_value(
|
|
161
|
+
value: datetime.date | datetime.datetime | str,
|
|
162
|
+
format_spec: str,
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Apply a strftime format spec to a temporal value.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
value: Python date/datetime or ISO temporal string.
|
|
168
|
+
format_spec: strftime-style format string (e.g. "%-d %b %Y").
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Formatted date string.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
ValueError: If the format spec contains an invalid/unknown directive.
|
|
175
|
+
"""
|
|
176
|
+
_validate_strftime_spec(format_spec)
|
|
177
|
+
|
|
178
|
+
if isinstance(value, str):
|
|
179
|
+
# Parse ISO string to a date object so strftime works uniformly.
|
|
180
|
+
# fromisoformat() on 3.10 doesn't handle the "Z" suffix — normalise it first.
|
|
181
|
+
# Space-separated Postgres timestamps ("YYYY-MM-DD HH:MM:SS") are normalised
|
|
182
|
+
# to ISO T-separator before parsing.
|
|
183
|
+
stripped = value.strip().replace("Z", "+00:00")
|
|
184
|
+
# Normalise space-separated timestamp to T-separator for fromisoformat().
|
|
185
|
+
if len(stripped) > 10 and stripped[10] == " ":
|
|
186
|
+
stripped = stripped[:10] + "T" + stripped[11:]
|
|
187
|
+
if "T" in stripped:
|
|
188
|
+
dt = datetime.datetime.fromisoformat(stripped)
|
|
189
|
+
if dt.tzinfo is not None:
|
|
190
|
+
d: datetime.date = dt.astimezone(datetime.timezone.utc).date()
|
|
191
|
+
else:
|
|
192
|
+
d = dt.date()
|
|
193
|
+
else:
|
|
194
|
+
d = datetime.date.fromisoformat(stripped[:10])
|
|
195
|
+
elif isinstance(value, datetime.datetime):
|
|
196
|
+
# Aware datetime: convert to UTC first to match ISO string behavior.
|
|
197
|
+
if value.tzinfo is not None:
|
|
198
|
+
d = value.astimezone(datetime.timezone.utc).date()
|
|
199
|
+
else:
|
|
200
|
+
d = value.date()
|
|
201
|
+
else:
|
|
202
|
+
d = value
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
return d.strftime(format_spec)
|
|
206
|
+
except ValueError as e:
|
|
207
|
+
raise ValueError(f"Invalid temporal format spec {format_spec!r}: {e}") from e
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _apply_column_defaults(
|
|
211
|
+
cfg: TableColumnConfig,
|
|
212
|
+
defaults: TableColumnDefaultsConfig,
|
|
213
|
+
) -> TableColumnConfig:
|
|
214
|
+
updates = {
|
|
215
|
+
name: getattr(defaults, name)
|
|
216
|
+
for name in type(defaults).model_fields
|
|
217
|
+
if getattr(cfg, name) is None and getattr(defaults, name) is not None
|
|
218
|
+
}
|
|
219
|
+
return cfg.model_copy(update=updates) if updates else cfg
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parse_table_column_configs(
|
|
223
|
+
style: TableChartStylePatch | None,
|
|
224
|
+
query_columns: list[str] | None = None,
|
|
225
|
+
) -> dict[str, TableColumnConfig]:
|
|
226
|
+
"""Parse column configurations from chart style into typed models.
|
|
227
|
+
|
|
228
|
+
When column_defaults is set and columns is unset, query_columns is used to
|
|
229
|
+
materialize a default config for every query-inferred column — enabling the
|
|
230
|
+
"no columns list" authoring shape where uniform presentation is set once at
|
|
231
|
+
the table style level.
|
|
232
|
+
"""
|
|
233
|
+
from dataface.core.compile.models.chart.authored import (
|
|
234
|
+
SparkConfig,
|
|
235
|
+
SparkTypeLiteral,
|
|
236
|
+
TableColumnConfig,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if not style:
|
|
240
|
+
return {}
|
|
241
|
+
|
|
242
|
+
columns_config = style.columns
|
|
243
|
+
defaults = style.column_defaults
|
|
244
|
+
|
|
245
|
+
# No explicit columns: if column_defaults is set, materialize defaults for
|
|
246
|
+
# every query-inferred column so authors need not list columns at all.
|
|
247
|
+
if not columns_config:
|
|
248
|
+
if defaults and query_columns:
|
|
249
|
+
return {
|
|
250
|
+
col: _apply_column_defaults(TableColumnConfig(), defaults)
|
|
251
|
+
for col in query_columns
|
|
252
|
+
}
|
|
253
|
+
return {}
|
|
254
|
+
|
|
255
|
+
result: dict[str, TableColumnConfig] = {}
|
|
256
|
+
for field_name, col_cfg in columns_config.items():
|
|
257
|
+
if isinstance(col_cfg, TableColumnConfig):
|
|
258
|
+
cfg = col_cfg
|
|
259
|
+
elif isinstance(col_cfg, dict):
|
|
260
|
+
cfg = TableColumnConfig.model_validate(col_cfg)
|
|
261
|
+
else:
|
|
262
|
+
continue
|
|
263
|
+
# Normalize spark string shorthand to SparkConfig so render code can
|
|
264
|
+
# use isinstance(cfg.spark, SparkConfig) uniformly.
|
|
265
|
+
if isinstance(cfg.spark, str):
|
|
266
|
+
cfg = cfg.model_copy(
|
|
267
|
+
update={"spark": SparkConfig(type=cast("SparkTypeLiteral", cfg.spark))}
|
|
268
|
+
)
|
|
269
|
+
result[field_name] = _apply_column_defaults(cfg, defaults) if defaults else cfg
|
|
270
|
+
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def parse_column_width(
|
|
275
|
+
width_hint: int | str | None, available_width: float
|
|
276
|
+
) -> float | None:
|
|
277
|
+
"""Resolve a column width hint into pixels."""
|
|
278
|
+
if width_hint is None:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
if isinstance(width_hint, int):
|
|
282
|
+
return float(width_hint) if width_hint > 0 else None
|
|
283
|
+
|
|
284
|
+
width_str = str(width_hint).strip()
|
|
285
|
+
if not width_str:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
if width_str.endswith("%"):
|
|
289
|
+
try:
|
|
290
|
+
pct = float(width_str[:-1])
|
|
291
|
+
except ValueError:
|
|
292
|
+
return None
|
|
293
|
+
return available_width * (pct / 100.0) if pct > 0 else None
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
px = float(width_str)
|
|
297
|
+
except ValueError:
|
|
298
|
+
return None
|
|
299
|
+
return px if px > 0 else None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def format_table_cell_value(
|
|
303
|
+
value: Any,
|
|
304
|
+
format_config: str | FormatConfig | None,
|
|
305
|
+
formats: dict[str, str] | None = None,
|
|
306
|
+
) -> str:
|
|
307
|
+
"""Format a cell value for display.
|
|
308
|
+
|
|
309
|
+
Temporal values (Python date/datetime objects and ISO timestamp strings)
|
|
310
|
+
are formatted before numeric values so DuckDB-typed columns show
|
|
311
|
+
human-readable dates without SQL-side strftime. The default format comes
|
|
312
|
+
from ``formats["date_short"]``; if that key is absent the call raises
|
|
313
|
+
``KeyError`` — a missing ``date_short`` is a theme cascade wiring bug,
|
|
314
|
+
not a "use built-in default" situation.
|
|
315
|
+
|
|
316
|
+
Explicit column ``format:`` strings containing ``%`` are treated as
|
|
317
|
+
strftime specs and applied to temporal values directly. Non-temporal
|
|
318
|
+
``format:`` strings (numeric d3 specs) raise ValueError.
|
|
319
|
+
"""
|
|
320
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
321
|
+
return "—"
|
|
322
|
+
|
|
323
|
+
if isinstance(value, (list, tuple)):
|
|
324
|
+
return f"[{len(value)} items]"
|
|
325
|
+
|
|
326
|
+
if isinstance(value, bool):
|
|
327
|
+
return str(value)
|
|
328
|
+
|
|
329
|
+
# Temporal path: date/datetime objects and ISO strings — must come before
|
|
330
|
+
# the numeric check because datetime.date is not int/float but authors
|
|
331
|
+
# may still set a format: spec that should be treated as a time format.
|
|
332
|
+
if is_temporal_value(value):
|
|
333
|
+
if format_config is not None:
|
|
334
|
+
resolved = resolve_format(format_config, formats)
|
|
335
|
+
# A strftime spec starts with (or solely contains) %-style directives.
|
|
336
|
+
# Distinguish from d3/numeric specs by requiring a leading % or %- modifier.
|
|
337
|
+
# d3 percent specs like ".0%" end with %, so a leading check is accurate.
|
|
338
|
+
if resolved.startswith("%"):
|
|
339
|
+
# Author-provided strftime spec — raises ValueError for bad directives.
|
|
340
|
+
return _format_temporal_value(value, resolved)
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"non-temporal format spec {resolved!r} applied to a temporal column; "
|
|
343
|
+
f"use a strftime spec (e.g. '%-d %b %Y') or remove format: to use date_short"
|
|
344
|
+
)
|
|
345
|
+
# The theme cascade must supply date_short. Missing formats or absent
|
|
346
|
+
# date_short is a wiring bug — raise so it's caught at dev time, not
|
|
347
|
+
# silently swallowed.
|
|
348
|
+
if formats is None:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
"formats dict is None — theme cascade failed to deliver date_short; "
|
|
351
|
+
"check that the default theme is loaded before rendering"
|
|
352
|
+
)
|
|
353
|
+
date_format = formats["date_short"]
|
|
354
|
+
return _format_temporal_value(value, date_format)
|
|
355
|
+
|
|
356
|
+
if isinstance(value, (int, float)):
|
|
357
|
+
if format_config is not None:
|
|
358
|
+
return format_value(value, format_config, formats)
|
|
359
|
+
if isinstance(value, float):
|
|
360
|
+
if value == int(value):
|
|
361
|
+
return str(int(value))
|
|
362
|
+
return f"{value:,.2f}"
|
|
363
|
+
return f"{value:,}"
|
|
364
|
+
|
|
365
|
+
if isinstance(value, dict):
|
|
366
|
+
return str(value)
|
|
367
|
+
if format_config is not None:
|
|
368
|
+
return format_value(value, format_config, formats)
|
|
369
|
+
return str(value)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def resolve_cell_link(
|
|
373
|
+
link: str,
|
|
374
|
+
row: dict[str, Any],
|
|
375
|
+
columns: list[str],
|
|
376
|
+
) -> str | None:
|
|
377
|
+
"""Resolve a cell link value for a single row."""
|
|
378
|
+
if link in columns:
|
|
379
|
+
val = row.get(link)
|
|
380
|
+
return str(val) if val is not None else None
|
|
381
|
+
|
|
382
|
+
if _TEMPLATE_RE.search(link):
|
|
383
|
+
has_none = False
|
|
384
|
+
|
|
385
|
+
def _sub(m: re.Match[str]) -> str:
|
|
386
|
+
nonlocal has_none
|
|
387
|
+
col_name = m.group(1)
|
|
388
|
+
val = row.get(col_name)
|
|
389
|
+
if val is None:
|
|
390
|
+
has_none = True
|
|
391
|
+
return ""
|
|
392
|
+
return str(val)
|
|
393
|
+
|
|
394
|
+
resolved = _TEMPLATE_RE.sub(_sub, link)
|
|
395
|
+
if has_none:
|
|
396
|
+
return None
|
|
397
|
+
return resolved or None
|
|
398
|
+
|
|
399
|
+
return link
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def resolve_cell_link_with_board(
|
|
403
|
+
link: str,
|
|
404
|
+
row: dict[str, Any],
|
|
405
|
+
columns: list[str],
|
|
406
|
+
) -> str | None:
|
|
407
|
+
"""Resolve a cell link and apply board-path rewriting if link context is set."""
|
|
408
|
+
resolved = resolve_cell_link(link, row, columns)
|
|
409
|
+
if resolved is None:
|
|
410
|
+
return None
|
|
411
|
+
ctx = get_link_context()
|
|
412
|
+
if ctx is None:
|
|
413
|
+
return resolved
|
|
414
|
+
return resolve_href(resolved, ctx)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def resolve_table_style_value(
|
|
418
|
+
spec: str | None,
|
|
419
|
+
row: dict[str, Any],
|
|
420
|
+
) -> str | None:
|
|
421
|
+
"""Resolve a table style override from a literal or per-row field ref."""
|
|
422
|
+
if spec is None:
|
|
423
|
+
return None
|
|
424
|
+
if spec in row:
|
|
425
|
+
value = row.get(spec)
|
|
426
|
+
return None if value in (None, "") else str(value)
|
|
427
|
+
return spec
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
# Row typing — per-row semantic roles (value / summary / total)
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
_VALID_ROW_ROLES = frozenset({"value", "summary", "total"})
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def resolve_row_role(row_role_spec: str | None, row: dict[str, Any]) -> str:
|
|
438
|
+
"""Resolve the row's semantic role using column-ID-first resolution.
|
|
439
|
+
|
|
440
|
+
Returns one of: "value" (default), "summary", "total".
|
|
441
|
+
Unknown values fall back to "value".
|
|
442
|
+
|
|
443
|
+
The row-role signal comes from the query layer (ADR-010: data and its
|
|
444
|
+
meaning belong to the query). DFT does not auto-detect summary rows
|
|
445
|
+
from content; the query must emit a column whose per-row value is
|
|
446
|
+
the role.
|
|
447
|
+
"""
|
|
448
|
+
if row_role_spec is None:
|
|
449
|
+
return "value"
|
|
450
|
+
raw = resolve_table_style_value(row_role_spec, row)
|
|
451
|
+
if raw is None or raw == "":
|
|
452
|
+
return "value"
|
|
453
|
+
normalized = raw.strip().lower()
|
|
454
|
+
if normalized in _VALID_ROW_ROLES:
|
|
455
|
+
return normalized
|
|
456
|
+
return "value"
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def is_summary_role(role: str) -> bool:
|
|
460
|
+
"""Return True if the role is summary or total (all summary rows)."""
|
|
461
|
+
return role in ("summary", "total")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def is_total_role(role: str) -> bool:
|
|
465
|
+
"""Return True only for total rows (which get double-rule treatment)."""
|
|
466
|
+
return role == "total"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _apply_rule_outputs(rule: ConditionalRule, result: dict[str, Any]) -> None:
|
|
470
|
+
"""Merge a rule's style outputs into ``result`` (last-match-wins)."""
|
|
471
|
+
if rule.background is not None:
|
|
472
|
+
result["background"] = rule.background
|
|
473
|
+
if rule.font is not None:
|
|
474
|
+
f = rule.font
|
|
475
|
+
if f.color is not None:
|
|
476
|
+
result["color"] = f.color
|
|
477
|
+
if f.weight is not None:
|
|
478
|
+
result["weight"] = font_weight_as_css(f.weight)
|
|
479
|
+
if f.style is not None:
|
|
480
|
+
result["style"] = f.style
|
|
481
|
+
if f.decoration is not None:
|
|
482
|
+
result["decoration"] = f.decoration
|
|
483
|
+
if rule.glyph is not None:
|
|
484
|
+
result["glyph"] = rule.glyph
|
|
485
|
+
# A later rule that swaps the glyph without specifying its color
|
|
486
|
+
# must not inherit the previous rule's color — clear it so the
|
|
487
|
+
# cell falls back to default ink.
|
|
488
|
+
result.pop("glyph_color", None)
|
|
489
|
+
if rule.glyph_color is not None:
|
|
490
|
+
result["glyph_color"] = rule.glyph_color
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def resolve_conditional_styles(
|
|
494
|
+
when_rules: Sequence[ConditionalRule] | None,
|
|
495
|
+
value: Any,
|
|
496
|
+
) -> dict[str, Any]:
|
|
497
|
+
"""Evaluate a list of ``when`` rules and return merged style overrides.
|
|
498
|
+
|
|
499
|
+
Non-default rules are evaluated in list order; all matching rules
|
|
500
|
+
contribute their style keys with last-match-wins semantics.
|
|
501
|
+
|
|
502
|
+
A trailing ``default: true`` rule is a catch-all — it only applies when
|
|
503
|
+
no non-default rule matched. This lets authors write
|
|
504
|
+
``[{lt:0, ...}, {gt:100, ...}, {default: true, ...}]`` and have the
|
|
505
|
+
default fire only for rows in (0, 100]. Position of the default rule is
|
|
506
|
+
validated on FieldConditionalFormatting; evaluation here is tolerant of
|
|
507
|
+
ordering.
|
|
508
|
+
|
|
509
|
+
Keys: ``background`` (str), ``color`` (str), ``weight`` (str),
|
|
510
|
+
``style`` (str), ``decoration`` (str).
|
|
511
|
+
"""
|
|
512
|
+
if not when_rules:
|
|
513
|
+
return {}
|
|
514
|
+
|
|
515
|
+
result: dict[str, Any] = {}
|
|
516
|
+
matched_any = False
|
|
517
|
+
default_rule: ConditionalRule | None = None
|
|
518
|
+
for rule in when_rules:
|
|
519
|
+
if rule.default is True:
|
|
520
|
+
default_rule = rule
|
|
521
|
+
continue
|
|
522
|
+
if match_predicate(rule, value):
|
|
523
|
+
matched_any = True
|
|
524
|
+
_apply_rule_outputs(rule, result)
|
|
525
|
+
if not matched_any and default_rule is not None:
|
|
526
|
+
_apply_rule_outputs(default_rule, result)
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def resolve_header_overflow(
|
|
531
|
+
style: TableChartStylePatch | None,
|
|
532
|
+
*,
|
|
533
|
+
inherited_title_style: Any | None,
|
|
534
|
+
table_config: Any,
|
|
535
|
+
promoted: str | None = None,
|
|
536
|
+
) -> TitleOverflowMode:
|
|
537
|
+
"""Resolve the effective table-header overflow mode.
|
|
538
|
+
|
|
539
|
+
Precedence (highest first):
|
|
540
|
+
1. ``promoted`` — ResolvedChart.header_overflow (the authored Patch
|
|
541
|
+
value lifted to a first-class field by resolve_chart).
|
|
542
|
+
2. ``style.header_overflow`` — fallback for Chart/MockChart
|
|
543
|
+
entry-boundary inputs that haven't been resolved yet.
|
|
544
|
+
3. ``table_config.header_overflow`` — face-level TableChartStyle override.
|
|
545
|
+
4. inherited title overflow.
|
|
546
|
+
"""
|
|
547
|
+
if promoted in {"clip", "truncate", "wrap-two", "wrap"}:
|
|
548
|
+
return cast(Literal["clip", "truncate", "wrap-two", "wrap"], promoted)
|
|
549
|
+
if style is not None:
|
|
550
|
+
raw = style.header_overflow
|
|
551
|
+
if raw in {"clip", "truncate", "wrap-two", "wrap"}:
|
|
552
|
+
return raw
|
|
553
|
+
raw_table = getattr(table_config, "header_overflow", None)
|
|
554
|
+
if raw_table in {"clip", "truncate", "wrap-two", "wrap"}:
|
|
555
|
+
return raw_table
|
|
556
|
+
return resolve_title_overflow(inherited_title_style)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# Compact-column classifier threshold. Any auto-column whose measured demand
|
|
560
|
+
# falls at or below this value is pinned to that demand rather than joining the
|
|
561
|
+
# proportional text pool. ~220px corresponds to roughly 30 characters at the
|
|
562
|
+
# default 13px body font — enough for short enums, numbers, and dates.
|
|
563
|
+
_COMPACT_DEMAND_CEILING: float = 220.0
|
|
564
|
+
|
|
565
|
+
# URL prefix filter for min-word floor measurement.
|
|
566
|
+
_URL_PREFIX_RE = re.compile(r"^https?://")
|
|
567
|
+
|
|
568
|
+
# Max token length included in min-word floor (tokens longer than this are
|
|
569
|
+
# opaque IDs / base64 chunks and would force an unreachable floor).
|
|
570
|
+
_MAX_FLOOR_TOKEN_LEN = 40
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _measure_min_word_width(
|
|
574
|
+
values: list[Any],
|
|
575
|
+
header_label: str,
|
|
576
|
+
measurer: Any,
|
|
577
|
+
font_size: float,
|
|
578
|
+
) -> float:
|
|
579
|
+
"""Return the pixel width of the widest eligible whitespace-delimited token.
|
|
580
|
+
|
|
581
|
+
Searches ``values`` (sampled cell strings) and the ``header_label`` for the
|
|
582
|
+
widest token, applying two filters:
|
|
583
|
+
- Skip tokens that start with ``http://`` or ``https://`` (URLs).
|
|
584
|
+
- Skip tokens longer than ``_MAX_FLOOR_TOKEN_LEN`` characters.
|
|
585
|
+
|
|
586
|
+
This gives the minimum column width that avoids mid-word line breaks.
|
|
587
|
+
"""
|
|
588
|
+
max_w = 0.0
|
|
589
|
+
|
|
590
|
+
def _candidate_tokens(text: str) -> Iterator[str]:
|
|
591
|
+
for token in text.split():
|
|
592
|
+
if _URL_PREFIX_RE.match(token):
|
|
593
|
+
continue
|
|
594
|
+
if len(token) > _MAX_FLOOR_TOKEN_LEN:
|
|
595
|
+
continue
|
|
596
|
+
yield token
|
|
597
|
+
|
|
598
|
+
for token in _candidate_tokens(header_label):
|
|
599
|
+
w = measurer.measure(token, font_size)
|
|
600
|
+
if w > max_w:
|
|
601
|
+
max_w = w
|
|
602
|
+
|
|
603
|
+
for val in values:
|
|
604
|
+
for token in _candidate_tokens(str(val)):
|
|
605
|
+
w = measurer.measure(token, font_size)
|
|
606
|
+
if w > max_w:
|
|
607
|
+
max_w = w
|
|
608
|
+
|
|
609
|
+
return max_w
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _classify_columns(
|
|
613
|
+
demands: dict[str, float],
|
|
614
|
+
column_configs: dict[str, TableColumnConfig],
|
|
615
|
+
) -> tuple[set[str], set[str]]:
|
|
616
|
+
"""Classify auto-columns as compact or text.
|
|
617
|
+
|
|
618
|
+
Compact columns are pinned to their measured demand; text columns compete
|
|
619
|
+
proportionally for the remaining budget.
|
|
620
|
+
|
|
621
|
+
A column is compact when:
|
|
622
|
+
- Its demand <= ``_COMPACT_DEMAND_CEILING``, OR
|
|
623
|
+
- It has a spark config (spark columns own their own width).
|
|
624
|
+
|
|
625
|
+
Returns ``(compact_keys, text_keys)``.
|
|
626
|
+
"""
|
|
627
|
+
from dataface.core.compile.models.chart.authored import SparkConfig
|
|
628
|
+
|
|
629
|
+
compact: set[str] = set()
|
|
630
|
+
text: set[str] = set()
|
|
631
|
+
for col, demand in demands.items():
|
|
632
|
+
cfg = column_configs.get(col)
|
|
633
|
+
has_spark = cfg is not None and isinstance(cfg.spark, SparkConfig)
|
|
634
|
+
if has_spark or demand <= _COMPACT_DEMAND_CEILING:
|
|
635
|
+
compact.add(col)
|
|
636
|
+
else:
|
|
637
|
+
text.add(col)
|
|
638
|
+
return compact, text
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# Anti-pathological cap: no single auto-column may claim more than this
|
|
642
|
+
# fraction of the auto-column budget in proportional allocation.
|
|
643
|
+
_PROPORTIONAL_CAP = 0.6
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _proportional_allocate(
|
|
647
|
+
demands: dict[str, float], budget: float
|
|
648
|
+
) -> dict[str, float]:
|
|
649
|
+
"""Allocate budget to columns proportionally to their demands.
|
|
650
|
+
|
|
651
|
+
Single-column: always gets the full budget.
|
|
652
|
+
Multi-column: iteratively caps any column at ``_PROPORTIONAL_CAP * budget``
|
|
653
|
+
and redistributes the excess among the uncapped columns until stable.
|
|
654
|
+
Budget is always fully consumed.
|
|
655
|
+
"""
|
|
656
|
+
if len(demands) <= 1:
|
|
657
|
+
col = next(iter(demands)) if demands else None
|
|
658
|
+
return {col: budget} if col is not None else {}
|
|
659
|
+
|
|
660
|
+
cap = budget * _PROPORTIONAL_CAP
|
|
661
|
+
active = dict(demands)
|
|
662
|
+
fixed: dict[str, float] = {}
|
|
663
|
+
remaining = budget
|
|
664
|
+
|
|
665
|
+
while active:
|
|
666
|
+
total = sum(active.values())
|
|
667
|
+
if total <= 0:
|
|
668
|
+
per_col = remaining / len(active)
|
|
669
|
+
fixed.update(dict.fromkeys(active, per_col))
|
|
670
|
+
break
|
|
671
|
+
|
|
672
|
+
to_fix = {
|
|
673
|
+
col: cap for col, d in active.items() if (d / total) * remaining > cap
|
|
674
|
+
}
|
|
675
|
+
if not to_fix:
|
|
676
|
+
for col, d in active.items():
|
|
677
|
+
fixed[col] = (d / total) * remaining
|
|
678
|
+
break
|
|
679
|
+
|
|
680
|
+
fixed.update(to_fix)
|
|
681
|
+
remaining -= sum(to_fix.values())
|
|
682
|
+
for col in to_fix:
|
|
683
|
+
del active[col]
|
|
684
|
+
|
|
685
|
+
return fixed
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _cluster_and_equalize_widths(
|
|
689
|
+
widths: dict[str, float], budget: float, threshold: float
|
|
690
|
+
) -> dict[str, float]:
|
|
691
|
+
"""Snap near-equal widths to a shared value, then rescale to budget.
|
|
692
|
+
|
|
693
|
+
Sorted walk: columns sorted ascending, clusters grow while
|
|
694
|
+
cluster_min / new_width >= threshold. This is the cluster extremes check —
|
|
695
|
+
not adjacent-pair — so 100/110/120/130/140/150 does NOT collapse into one
|
|
696
|
+
cluster even though each adjacent ratio is >= 0.8. Each cluster snaps to
|
|
697
|
+
its max. A final uniform rescale restores the budget, preserving
|
|
698
|
+
within-cluster equality.
|
|
699
|
+
|
|
700
|
+
threshold=1.0 means only exactly-equal widths cluster (effectively disabled).
|
|
701
|
+
threshold=0.0 means all widths cluster (force-equal).
|
|
702
|
+
"""
|
|
703
|
+
if len(widths) <= 1:
|
|
704
|
+
return dict(widths)
|
|
705
|
+
|
|
706
|
+
sorted_items = sorted(widths.items(), key=lambda kv: kv[1])
|
|
707
|
+
|
|
708
|
+
clusters: list[list[str]] = []
|
|
709
|
+
cluster_cols: list[str] = []
|
|
710
|
+
cluster_min = sorted_items[0][1]
|
|
711
|
+
|
|
712
|
+
for col, w in sorted_items:
|
|
713
|
+
if cluster_cols and cluster_min / (w + 1e-9) < threshold:
|
|
714
|
+
clusters.append(cluster_cols)
|
|
715
|
+
cluster_cols = [col]
|
|
716
|
+
cluster_min = w
|
|
717
|
+
else:
|
|
718
|
+
cluster_cols.append(col)
|
|
719
|
+
clusters.append(cluster_cols)
|
|
720
|
+
|
|
721
|
+
snapped: dict[str, float] = {}
|
|
722
|
+
col_to_w = dict(widths)
|
|
723
|
+
for cluster in clusters:
|
|
724
|
+
cluster_max = max(col_to_w[c] for c in cluster)
|
|
725
|
+
for c in cluster:
|
|
726
|
+
snapped[c] = cluster_max
|
|
727
|
+
|
|
728
|
+
total = sum(snapped.values())
|
|
729
|
+
if total > 0:
|
|
730
|
+
scale = budget / total
|
|
731
|
+
return {c: v * scale for c, v in snapped.items()}
|
|
732
|
+
return snapped
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def measure_column_demands(
|
|
736
|
+
columns: list[str],
|
|
737
|
+
column_configs: dict[str, TableColumnConfig],
|
|
738
|
+
data: list[dict[str, Any]],
|
|
739
|
+
measurer: Any,
|
|
740
|
+
*,
|
|
741
|
+
font_size: float,
|
|
742
|
+
header_font_size: float,
|
|
743
|
+
header_case: CaseValue = "none",
|
|
744
|
+
cell_pad: int,
|
|
745
|
+
max_sample_rows: int = 50,
|
|
746
|
+
formats: dict[str, str] | None = None,
|
|
747
|
+
header_visible: bool = True,
|
|
748
|
+
) -> dict[str, float]:
|
|
749
|
+
"""Measure total-width demand per column (header + p95 cell content + padding).
|
|
750
|
+
|
|
751
|
+
Returns a dict mapping column name → demanded pixel width including both
|
|
752
|
+
sides of cell padding. Callers pass this to ``calculate_column_layout``
|
|
753
|
+
as the ``demands`` kwarg to enable proportional budget allocation.
|
|
754
|
+
|
|
755
|
+
When ``header_visible`` is False, the header label is excluded from the
|
|
756
|
+
demand so a hidden header can't inflate column width above the cell content.
|
|
757
|
+
|
|
758
|
+
Swatch columns are pinned to ``_SWATCH_CELL_DEMAND`` (rect width plus cell
|
|
759
|
+
padding); their cell value is a color string, not rendered as text, so
|
|
760
|
+
measuring it would massively over-allocate.
|
|
761
|
+
"""
|
|
762
|
+
demands: dict[str, float] = {}
|
|
763
|
+
sample = data[:max_sample_rows]
|
|
764
|
+
|
|
765
|
+
for col in columns:
|
|
766
|
+
col_config = column_configs.get(col)
|
|
767
|
+
if col_config and col_config.swatch:
|
|
768
|
+
demands[col] = _SWATCH_CELL_DEMAND + cell_pad * 2
|
|
769
|
+
continue
|
|
770
|
+
if not header_visible:
|
|
771
|
+
header_w = 0.0
|
|
772
|
+
else:
|
|
773
|
+
display_name = (col_config.label if col_config else None) or slug_to_text(
|
|
774
|
+
col
|
|
775
|
+
)
|
|
776
|
+
display_name = apply_case(display_name, header_case)
|
|
777
|
+
header_w = measurer.measure(display_name, header_font_size)
|
|
778
|
+
|
|
779
|
+
cell_widths: list[float] = []
|
|
780
|
+
for row in sample:
|
|
781
|
+
val = row.get(col, "")
|
|
782
|
+
fmt = col_config.format if col_config else None
|
|
783
|
+
rendered = format_table_cell_value(val, fmt, formats)
|
|
784
|
+
cell_widths.append(measurer.measure(rendered, font_size))
|
|
785
|
+
|
|
786
|
+
if cell_widths:
|
|
787
|
+
cell_widths.sort()
|
|
788
|
+
p95_idx = min(int(len(cell_widths) * 0.95), len(cell_widths) - 1)
|
|
789
|
+
p95_w = cell_widths[p95_idx]
|
|
790
|
+
else:
|
|
791
|
+
p95_w = 0.0
|
|
792
|
+
|
|
793
|
+
demands[col] = max(header_w, p95_w) + cell_pad * 2
|
|
794
|
+
|
|
795
|
+
return demands
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def measure_column_word_floors(
|
|
799
|
+
columns: list[str],
|
|
800
|
+
column_configs: dict[str, TableColumnConfig],
|
|
801
|
+
data: list[dict[str, Any]],
|
|
802
|
+
measurer: Any,
|
|
803
|
+
*,
|
|
804
|
+
font_size: float,
|
|
805
|
+
header_case: CaseValue = "none",
|
|
806
|
+
cell_pad: int = 0,
|
|
807
|
+
max_sample_rows: int = 50,
|
|
808
|
+
header_visible: bool = True,
|
|
809
|
+
) -> dict[str, float]:
|
|
810
|
+
"""Return the min-word floor (px) for each column including cell padding.
|
|
811
|
+
|
|
812
|
+
The floor is the pixel width of the widest whitespace-delimited token in
|
|
813
|
+
any sampled cell value or the column header label, plus ``cell_pad * 2``
|
|
814
|
+
(both sides of cell padding). URL tokens and tokens longer than
|
|
815
|
+
``_MAX_FLOOR_TOKEN_LEN`` are excluded (see ``_measure_min_word_width``).
|
|
816
|
+
|
|
817
|
+
The padding offset ensures that when the layout assigns a column a width
|
|
818
|
+
equal to its floor, the content area (``width - 2 * cell_pad``) is still
|
|
819
|
+
wide enough to display the longest token without mid-word wrapping.
|
|
820
|
+
|
|
821
|
+
Callers pass this to ``calculate_column_layout`` as the ``word_floors``
|
|
822
|
+
kwarg so that text-column allocations are never narrower than their longest
|
|
823
|
+
non-URL word.
|
|
824
|
+
|
|
825
|
+
When ``header_visible`` is False, the header label is excluded — a hidden
|
|
826
|
+
label can't drive a min-width floor.
|
|
827
|
+
|
|
828
|
+
Swatch columns pin to the rect width (no text), bypassing token measurement.
|
|
829
|
+
"""
|
|
830
|
+
floors: dict[str, float] = {}
|
|
831
|
+
sample = data[:max_sample_rows]
|
|
832
|
+
|
|
833
|
+
for col in columns:
|
|
834
|
+
col_config = column_configs.get(col)
|
|
835
|
+
if col_config and col_config.swatch:
|
|
836
|
+
floors[col] = _SWATCH_CELL_DEMAND + cell_pad * 2
|
|
837
|
+
continue
|
|
838
|
+
if not header_visible:
|
|
839
|
+
display_name = ""
|
|
840
|
+
else:
|
|
841
|
+
display_name = (col_config.label if col_config else None) or slug_to_text(
|
|
842
|
+
col
|
|
843
|
+
)
|
|
844
|
+
display_name = apply_case(display_name, header_case)
|
|
845
|
+
|
|
846
|
+
cell_values = [row.get(col, "") for row in sample]
|
|
847
|
+
token_w = _measure_min_word_width(
|
|
848
|
+
cell_values, display_name, measurer, font_size
|
|
849
|
+
)
|
|
850
|
+
floors[col] = token_w + cell_pad * 2
|
|
851
|
+
|
|
852
|
+
return floors
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _allocate_text_columns(
|
|
856
|
+
text_keys: set[str],
|
|
857
|
+
text_demands: dict[str, float],
|
|
858
|
+
text_budget: float,
|
|
859
|
+
max_widths: dict[str, float | None],
|
|
860
|
+
word_floors: dict[str, float],
|
|
861
|
+
width_similarity_threshold: float,
|
|
862
|
+
) -> dict[str, float]:
|
|
863
|
+
"""Proportionally allocate ``text_budget`` among text columns.
|
|
864
|
+
|
|
865
|
+
Enforces per-column ``max_width`` caps through iterative redistribution.
|
|
866
|
+
Applies ``word_floors`` as a minimum (clamped at ``max_width`` so a floor
|
|
867
|
+
never silently exceeds a declared cap). Cluster-equalizes uncapped columns.
|
|
868
|
+
"""
|
|
869
|
+
if len(text_keys) == 1:
|
|
870
|
+
(only_col,) = text_keys
|
|
871
|
+
mw = max_widths[only_col]
|
|
872
|
+
floor = word_floors.get(only_col, 0.0)
|
|
873
|
+
# Floor is clamped at max_width — max_width is the hard upper bound.
|
|
874
|
+
effective_floor = floor if mw is None else min(floor, mw)
|
|
875
|
+
allocated_w = text_budget if mw is None else min(text_budget, mw)
|
|
876
|
+
return {only_col: max(allocated_w, effective_floor)}
|
|
877
|
+
|
|
878
|
+
text_allocated = _proportional_allocate(text_demands, text_budget)
|
|
879
|
+
|
|
880
|
+
# Iteratively cap columns at max_width and redistribute leftover to uncapped
|
|
881
|
+
# columns. Each iteration resolves at least one more column; the loop is
|
|
882
|
+
# guaranteed to terminate in at most len(text_keys) passes.
|
|
883
|
+
final_text: dict[str, float] = {}
|
|
884
|
+
to_allocate = dict(text_allocated)
|
|
885
|
+
|
|
886
|
+
while True:
|
|
887
|
+
capped_now: dict[str, float] = {}
|
|
888
|
+
free: dict[str, float] = {}
|
|
889
|
+
leftover = 0.0
|
|
890
|
+
for col, w in to_allocate.items():
|
|
891
|
+
mw = max_widths[col]
|
|
892
|
+
if mw is not None and w > mw:
|
|
893
|
+
capped_now[col] = mw
|
|
894
|
+
leftover += w - mw
|
|
895
|
+
else:
|
|
896
|
+
free[col] = w
|
|
897
|
+
final_text.update(capped_now)
|
|
898
|
+
if not leftover or not free:
|
|
899
|
+
final_text.update(free)
|
|
900
|
+
break
|
|
901
|
+
# Redistribute leftover to uncapped columns proportionally.
|
|
902
|
+
total_free = sum(free.values())
|
|
903
|
+
if total_free > 0:
|
|
904
|
+
new_total = total_free + leftover
|
|
905
|
+
for col in free:
|
|
906
|
+
free[col] = free[col] / total_free * new_total
|
|
907
|
+
to_allocate = free
|
|
908
|
+
|
|
909
|
+
# Cluster-equalize only non-capped columns; capped columns are excluded
|
|
910
|
+
# so the snap-to-cluster-max cannot push them above their declared cap.
|
|
911
|
+
non_capped = {c: v for c, v in final_text.items() if max_widths[c] is None}
|
|
912
|
+
capped_only = {c: v for c, v in final_text.items() if max_widths[c] is not None}
|
|
913
|
+
if len(non_capped) > 1:
|
|
914
|
+
non_capped = _cluster_and_equalize_widths(
|
|
915
|
+
non_capped,
|
|
916
|
+
sum(non_capped.values()),
|
|
917
|
+
threshold=width_similarity_threshold,
|
|
918
|
+
)
|
|
919
|
+
final_text = {**capped_only, **non_capped}
|
|
920
|
+
|
|
921
|
+
# Apply word_floors after all allocation. Floor is clamped at max_width
|
|
922
|
+
# so a word-floor wider than a declared cap cannot silently override it.
|
|
923
|
+
for col in text_keys:
|
|
924
|
+
floor = word_floors.get(col, 0.0)
|
|
925
|
+
mw = max_widths[col]
|
|
926
|
+
effective_floor = floor if mw is None else min(floor, mw)
|
|
927
|
+
if effective_floor > 0 and final_text[col] < effective_floor:
|
|
928
|
+
final_text[col] = effective_floor
|
|
929
|
+
|
|
930
|
+
return final_text
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def calculate_column_layout(
|
|
934
|
+
columns: list[str],
|
|
935
|
+
column_configs: dict[str, TableColumnConfig],
|
|
936
|
+
available_width: float,
|
|
937
|
+
*,
|
|
938
|
+
demands: dict[str, float] | None = None,
|
|
939
|
+
width_similarity_threshold: float = 0.8,
|
|
940
|
+
word_floors: dict[str, float] | None = None,
|
|
941
|
+
) -> tuple[dict[str, float], list[float], float]:
|
|
942
|
+
"""Compute per-column widths, x offsets, and actual content width.
|
|
943
|
+
|
|
944
|
+
Returns ``(col_widths, col_x_offsets, actual_content_width)``.
|
|
945
|
+
|
|
946
|
+
When ``demands`` is provided and there are multiple auto-columns:
|
|
947
|
+
|
|
948
|
+
1. Columns with ``width:`` set are pinned and removed from the auto pool.
|
|
949
|
+
2. Auto-columns are classified as *compact* or *text* (``_classify_columns``).
|
|
950
|
+
Compact columns are pinned to their measured demand; text columns share
|
|
951
|
+
the remaining budget via ``_allocate_text_columns``.
|
|
952
|
+
3. Any leftover budget (e.g. from ``max_width:`` caps) goes to compact
|
|
953
|
+
columns proportionally. When no compact columns exist, the gap is left
|
|
954
|
+
unconsumed — clipping is honest, mid-word wrapping is not.
|
|
955
|
+
4. All compact, no text: compact widths scale up to fill the budget.
|
|
956
|
+
|
|
957
|
+
Without ``demands`` or for a single auto-column: equal distribution.
|
|
958
|
+
"""
|
|
959
|
+
col_widths: dict[str, float] = {}
|
|
960
|
+
if not columns:
|
|
961
|
+
col_x_offsets: list[float] = []
|
|
962
|
+
return col_widths, col_x_offsets, available_width
|
|
963
|
+
|
|
964
|
+
# --- Phase 1: resolve explicit width: pins ---
|
|
965
|
+
explicit_widths: dict[str, float] = {}
|
|
966
|
+
for col in columns:
|
|
967
|
+
col_cfg = column_configs.get(col)
|
|
968
|
+
width_hint = parse_column_width(
|
|
969
|
+
col_cfg.width if col_cfg else None, available_width
|
|
970
|
+
)
|
|
971
|
+
if width_hint is not None:
|
|
972
|
+
explicit_widths[col] = width_hint
|
|
973
|
+
|
|
974
|
+
auto_columns = [col for col in columns if col not in explicit_widths]
|
|
975
|
+
explicit_total = sum(explicit_widths.values())
|
|
976
|
+
available_for_auto = max(available_width - explicit_total, 0.0)
|
|
977
|
+
|
|
978
|
+
if not auto_columns:
|
|
979
|
+
col_widths.update(explicit_widths)
|
|
980
|
+
actual_content_width = sum(col_widths.get(col, 0) for col in columns)
|
|
981
|
+
col_x_offsets = []
|
|
982
|
+
current_x = 0.0
|
|
983
|
+
for col in columns:
|
|
984
|
+
col_x_offsets.append(current_x)
|
|
985
|
+
current_x += col_widths.get(col, 100)
|
|
986
|
+
return col_widths, col_x_offsets, actual_content_width
|
|
987
|
+
|
|
988
|
+
# --- Phase 2: smart allocation when demands are provided ---
|
|
989
|
+
if demands and len(auto_columns) > 1:
|
|
990
|
+
auto_demands = {col: max(demands.get(col, 1.0), 1.0) for col in auto_columns}
|
|
991
|
+
compact_keys, text_keys = _classify_columns(auto_demands, column_configs)
|
|
992
|
+
|
|
993
|
+
compact_pinned: dict[str, float] = {c: auto_demands[c] for c in compact_keys}
|
|
994
|
+
compact_total = sum(compact_pinned.values())
|
|
995
|
+
text_budget = max(available_for_auto - compact_total, 0.0)
|
|
996
|
+
|
|
997
|
+
if not text_keys:
|
|
998
|
+
# All compact: scale proportionally to fill (or fit) the budget.
|
|
999
|
+
total_compact_demand = sum(compact_pinned.values())
|
|
1000
|
+
if total_compact_demand > 0:
|
|
1001
|
+
scale = available_for_auto / total_compact_demand
|
|
1002
|
+
col_widths.update({c: w * scale for c, w in compact_pinned.items()})
|
|
1003
|
+
else:
|
|
1004
|
+
per_col = available_for_auto / len(auto_columns)
|
|
1005
|
+
col_widths.update(dict.fromkeys(auto_columns, per_col))
|
|
1006
|
+
else:
|
|
1007
|
+
# Resolve per-column max_width caps.
|
|
1008
|
+
max_widths: dict[str, float | None] = {}
|
|
1009
|
+
for col in text_keys:
|
|
1010
|
+
cfg = column_configs.get(col)
|
|
1011
|
+
if cfg is not None and cfg.max_width is not None:
|
|
1012
|
+
max_widths[col] = parse_column_width(cfg.max_width, available_width)
|
|
1013
|
+
else:
|
|
1014
|
+
max_widths[col] = None
|
|
1015
|
+
|
|
1016
|
+
text_result = _allocate_text_columns(
|
|
1017
|
+
text_keys,
|
|
1018
|
+
{c: auto_demands[c] for c in text_keys},
|
|
1019
|
+
text_budget,
|
|
1020
|
+
max_widths,
|
|
1021
|
+
word_floors or {},
|
|
1022
|
+
width_similarity_threshold,
|
|
1023
|
+
)
|
|
1024
|
+
col_widths.update(text_result)
|
|
1025
|
+
|
|
1026
|
+
# Any leftover after text allocation (e.g. from max_width caps) goes
|
|
1027
|
+
# to compact columns proportionally. No compact → gap is left
|
|
1028
|
+
# unconsumed (accepting underflow rather than violating a cap).
|
|
1029
|
+
text_used = sum(col_widths.get(c, 0.0) for c in text_keys)
|
|
1030
|
+
remainder = available_for_auto - compact_total - text_used
|
|
1031
|
+
if remainder > 0.1 and compact_pinned:
|
|
1032
|
+
total_compact = sum(compact_pinned.values())
|
|
1033
|
+
if total_compact > 0:
|
|
1034
|
+
for col in compact_pinned:
|
|
1035
|
+
compact_pinned[col] += remainder * (
|
|
1036
|
+
compact_pinned[col] / total_compact
|
|
1037
|
+
)
|
|
1038
|
+
else:
|
|
1039
|
+
per_compact = remainder / len(compact_pinned)
|
|
1040
|
+
for col in compact_pinned:
|
|
1041
|
+
compact_pinned[col] += per_compact
|
|
1042
|
+
|
|
1043
|
+
col_widths.update(compact_pinned)
|
|
1044
|
+
else:
|
|
1045
|
+
# Fallback: equal distribution (no demands, or single auto-column)
|
|
1046
|
+
per_col = available_for_auto / len(auto_columns)
|
|
1047
|
+
for col in auto_columns:
|
|
1048
|
+
col_widths[col] = per_col
|
|
1049
|
+
|
|
1050
|
+
col_widths.update(explicit_widths)
|
|
1051
|
+
actual_content_width = sum(col_widths.get(col, 0) for col in columns)
|
|
1052
|
+
|
|
1053
|
+
col_x_offsets = []
|
|
1054
|
+
current_x = 0.0
|
|
1055
|
+
for col in columns:
|
|
1056
|
+
col_x_offsets.append(current_x)
|
|
1057
|
+
current_x += col_widths.get(col, 100)
|
|
1058
|
+
return col_widths, col_x_offsets, actual_content_width
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def resolve_wrapped_headers(
|
|
1062
|
+
columns: list[str],
|
|
1063
|
+
column_configs: dict[str, TableColumnConfig],
|
|
1064
|
+
col_widths: dict[str, float],
|
|
1065
|
+
*,
|
|
1066
|
+
header_overflow: TitleOverflowMode,
|
|
1067
|
+
header_height: float,
|
|
1068
|
+
header_font: FontStyle,
|
|
1069
|
+
padding: int,
|
|
1070
|
+
table_config: Any,
|
|
1071
|
+
cell_pad: int | None = None,
|
|
1072
|
+
measurer: Any,
|
|
1073
|
+
) -> tuple[dict[str, list[str]], float]:
|
|
1074
|
+
"""Return wrapped header lines and the effective header height.
|
|
1075
|
+
|
|
1076
|
+
Wrapping uses the full cell content area (``cw - 2 * pad``) for every
|
|
1077
|
+
column — both text and numeric headers get the same available width.
|
|
1078
|
+
Wide headers wrap into the next line when they'd overflow.
|
|
1079
|
+
|
|
1080
|
+
Wrapping decisions always use real glyph metrics via ``measurer`` to
|
|
1081
|
+
avoid false-positive wraps (e.g. "Poverty" wrapping at widths where it
|
|
1082
|
+
actually fits).
|
|
1083
|
+
"""
|
|
1084
|
+
if not columns:
|
|
1085
|
+
return {}, header_height
|
|
1086
|
+
|
|
1087
|
+
pad = cell_pad if cell_pad is not None else table_config.columns.cell_padding
|
|
1088
|
+
header_font_size = int(header_font.size) if header_font.size is not None else 12
|
|
1089
|
+
header_line_height = header_font_size + table_config.text_baseline_offset
|
|
1090
|
+
wrapped_headers: dict[str, list[str]] = {}
|
|
1091
|
+
|
|
1092
|
+
for col in columns:
|
|
1093
|
+
cw = col_widths.get(col, 100)
|
|
1094
|
+
col_config = column_configs.get(col)
|
|
1095
|
+
name = (col_config.label if col_config else None) or slug_to_text(col)
|
|
1096
|
+
# Apply header case transform so wrapped lines match what render_table_headers emits.
|
|
1097
|
+
_header_case = header_font.case
|
|
1098
|
+
if _header_case is not None and _header_case != "none":
|
|
1099
|
+
from dataface.core.render.text.case import apply_case
|
|
1100
|
+
|
|
1101
|
+
name = apply_case(name, _header_case)
|
|
1102
|
+
column_overflow: TitleOverflowMode = (
|
|
1103
|
+
col_config.header_overflow
|
|
1104
|
+
if col_config and col_config.header_overflow is not None
|
|
1105
|
+
else header_overflow
|
|
1106
|
+
)
|
|
1107
|
+
content_area = max(int(cw - pad * 2), 1)
|
|
1108
|
+
# Short-circuit: if the full header fits on one line under real font
|
|
1109
|
+
# metrics, skip the char-count heuristic entirely.
|
|
1110
|
+
if measurer.measure(name, header_font_size) <= content_area:
|
|
1111
|
+
wrapped_headers[col] = [name]
|
|
1112
|
+
continue
|
|
1113
|
+
rendered = prepare_title_text(
|
|
1114
|
+
name,
|
|
1115
|
+
overflow=column_overflow,
|
|
1116
|
+
limit=content_area,
|
|
1117
|
+
font_size=header_font_size,
|
|
1118
|
+
font_family=header_font.family,
|
|
1119
|
+
)
|
|
1120
|
+
wrapped_headers[col] = rendered.splitlines() or [name]
|
|
1121
|
+
max_lines = max(len(lines) for lines in wrapped_headers.values())
|
|
1122
|
+
effective_height = max(header_height, padding * 2 + max_lines * header_line_height)
|
|
1123
|
+
return wrapped_headers, effective_height
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
# ---------------------------------------------------------------------------
|
|
1127
|
+
# Conditional formatting helpers (when rules + scale color mapping)
|
|
1128
|
+
# ---------------------------------------------------------------------------
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _expand_hex(c: str) -> str:
|
|
1132
|
+
"""Expand 3-char hex (#RGB) to 6-char (#RRGGBB). Passes 6-char through."""
|
|
1133
|
+
h = c.lstrip("#")
|
|
1134
|
+
if len(h) == 3:
|
|
1135
|
+
h = "".join(ch * 2 for ch in h)
|
|
1136
|
+
return f"#{h}"
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _parse_hex(c: str) -> tuple[int, int, int]:
|
|
1140
|
+
h = _expand_hex(c).lstrip("#")
|
|
1141
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _lerp_color(c1: str, c2: str, t: float) -> str:
|
|
1145
|
+
"""Linearly interpolate between two hex colors."""
|
|
1146
|
+
r1, g1, b1 = _parse_hex(c1)
|
|
1147
|
+
r2, g2, b2 = _parse_hex(c2)
|
|
1148
|
+
r = int(r1 + (r2 - r1) * t + 0.5)
|
|
1149
|
+
g = int(g1 + (g2 - g1) * t + 0.5)
|
|
1150
|
+
b = int(b1 + (b2 - b1) * t + 0.5)
|
|
1151
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def resolve_palette_stops(
|
|
1155
|
+
ref: str | list[str] | list[float],
|
|
1156
|
+
*,
|
|
1157
|
+
surface: Literal["default", "table"] | None = None,
|
|
1158
|
+
) -> list[str]:
|
|
1159
|
+
"""Resolve a palette reference to a list of hex stops.
|
|
1160
|
+
|
|
1161
|
+
Named string → resolved via M2 palette resolver. Pass ``surface="table"``
|
|
1162
|
+
to get a WCAG-safe sub-palette suitable for table cell backgrounds (sequential
|
|
1163
|
+
and diverging only; inline lists are returned unchanged regardless of surface).
|
|
1164
|
+
|
|
1165
|
+
Inline list passes through unchanged (new list object returned).
|
|
1166
|
+
|
|
1167
|
+
``list[float]`` is accepted to match the schema union but is not
|
|
1168
|
+
meaningful for render; callers that pass numeric lists will still see
|
|
1169
|
+
garbage colors from ``_expand_hex``.
|
|
1170
|
+
"""
|
|
1171
|
+
if isinstance(ref, list):
|
|
1172
|
+
if not ref:
|
|
1173
|
+
raise ValueError("palette must not be empty")
|
|
1174
|
+
return [str(c) for c in ref]
|
|
1175
|
+
return _resolve_named_palette(ref, surface=surface)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def resolve_hinge(
|
|
1179
|
+
config: ScaleTargetConfig,
|
|
1180
|
+
lo: float,
|
|
1181
|
+
hi: float,
|
|
1182
|
+
col_format: str | None,
|
|
1183
|
+
) -> float | None:
|
|
1184
|
+
"""Decide the diverging pivot from config + domain context.
|
|
1185
|
+
|
|
1186
|
+
Returns ``None`` for sequential (``config.hinge is None``).
|
|
1187
|
+
Explicit float in ``config.hinge`` short-circuits the decision tree.
|
|
1188
|
+
``"auto"`` runs the tree: zero-crossing → 0; percent-format crosses 1.0
|
|
1189
|
+
→ 1.0; else midpoint.
|
|
1190
|
+
"""
|
|
1191
|
+
if config.hinge is None:
|
|
1192
|
+
return None
|
|
1193
|
+
# Degenerate domain: diverging is meaningless; fall back to sequential.
|
|
1194
|
+
if lo == hi:
|
|
1195
|
+
return None
|
|
1196
|
+
if isinstance(config.hinge, (int, float)):
|
|
1197
|
+
return float(config.hinge)
|
|
1198
|
+
# "auto" decision tree.
|
|
1199
|
+
if lo < 0.0 < hi or (lo < 0.0 and hi == 0.0):
|
|
1200
|
+
return 0.0
|
|
1201
|
+
is_percent = col_format is not None and "%" in col_format
|
|
1202
|
+
if is_percent and lo < 1.0 < hi:
|
|
1203
|
+
return 1.0
|
|
1204
|
+
return (lo + hi) / 2.0
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _interpolate_arm(t: float, stops: list[str]) -> str:
|
|
1208
|
+
"""Interpolate at normalized position t∈[0,1] along a palette arm.
|
|
1209
|
+
|
|
1210
|
+
``stops`` must already be expanded to 6-char hex. ``t`` is clamped to
|
|
1211
|
+
[0, 1] before indexing.
|
|
1212
|
+
"""
|
|
1213
|
+
t = max(0.0, min(1.0, t))
|
|
1214
|
+
n_segments = len(stops) - 1
|
|
1215
|
+
segment = t * n_segments
|
|
1216
|
+
i = min(int(segment), n_segments - 1)
|
|
1217
|
+
return _lerp_color(stops[i], stops[i + 1], segment - i)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def interpolate_scale_color(
|
|
1221
|
+
value: float,
|
|
1222
|
+
min_val: float,
|
|
1223
|
+
max_val: float,
|
|
1224
|
+
palette: list[str],
|
|
1225
|
+
*,
|
|
1226
|
+
hinge: float | None = None,
|
|
1227
|
+
arm_mode: str = "asymmetric",
|
|
1228
|
+
) -> str:
|
|
1229
|
+
"""Map a numeric value to a color via linear interpolation across palette stops.
|
|
1230
|
+
|
|
1231
|
+
Sequential mode (``hinge is None``):
|
|
1232
|
+
Values below min clamp to the first stop; above max clamp to the last.
|
|
1233
|
+
When min == max, returns the middle stop.
|
|
1234
|
+
|
|
1235
|
+
Diverging mode (``hinge is not None``):
|
|
1236
|
+
Palette is split at the midpoint. Left half maps to [min_val, hinge];
|
|
1237
|
+
right half maps to [hinge, max_val].
|
|
1238
|
+
|
|
1239
|
+
``arm_mode="asymmetric"`` (default): each arm's t is computed relative
|
|
1240
|
+
to the actual arm width — per-unit intensity is consistent across the
|
|
1241
|
+
pivot regardless of arm length.
|
|
1242
|
+
|
|
1243
|
+
``arm_mode="symmetric"``: each arm stretches fully from neutral to
|
|
1244
|
+
its extreme regardless of absolute width — useful when visual parity
|
|
1245
|
+
between arms matters more than per-unit consistency.
|
|
1246
|
+
"""
|
|
1247
|
+
palette = [_expand_hex(c) for c in palette]
|
|
1248
|
+
|
|
1249
|
+
if hinge is None:
|
|
1250
|
+
# Sequential path — unchanged behavior.
|
|
1251
|
+
if min_val == max_val:
|
|
1252
|
+
return palette[len(palette) // 2]
|
|
1253
|
+
t = (value - min_val) / (max_val - min_val)
|
|
1254
|
+
t = max(0.0, min(1.0, t))
|
|
1255
|
+
n_segments = len(palette) - 1
|
|
1256
|
+
segment = t * n_segments
|
|
1257
|
+
i = min(int(segment), n_segments - 1)
|
|
1258
|
+
return _lerp_color(palette[i], palette[i + 1], segment - i)
|
|
1259
|
+
|
|
1260
|
+
# Diverging path.
|
|
1261
|
+
if len(palette) < 3 or len(palette) % 2 == 0:
|
|
1262
|
+
raise ValueError(
|
|
1263
|
+
f"diverging palette must have an odd number of stops >= 3, got {len(palette)}"
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
# Clamp hinge to [min_val, max_val] so out-of-domain author values don't
|
|
1267
|
+
# produce negative arm widths or meaningless interpolation.
|
|
1268
|
+
hinge = max(min_val, min(max_val, hinge))
|
|
1269
|
+
|
|
1270
|
+
mid = len(palette) // 2
|
|
1271
|
+
neg_stops = palette[: mid + 1][::-1] # neutral → neg extreme
|
|
1272
|
+
pos_stops = palette[mid:] # neutral → pos extreme
|
|
1273
|
+
|
|
1274
|
+
# Clamp value to domain.
|
|
1275
|
+
clamped = max(min_val, min(max_val, value))
|
|
1276
|
+
|
|
1277
|
+
neg_width = hinge - min_val
|
|
1278
|
+
pos_width = max_val - hinge
|
|
1279
|
+
# Asymmetric uses the longer arm as the common intensity unit so that
|
|
1280
|
+
# equal absolute distance from the hinge produces equal palette depth
|
|
1281
|
+
# on both sides — the shorter arm will never reach full palette saturation.
|
|
1282
|
+
# Symmetric uses per-arm width so both arms always fill their full palette
|
|
1283
|
+
# range regardless of absolute length.
|
|
1284
|
+
if clamped <= hinge:
|
|
1285
|
+
distance = hinge - clamped
|
|
1286
|
+
if arm_mode == "asymmetric":
|
|
1287
|
+
denom = max(neg_width, pos_width, 1e-12)
|
|
1288
|
+
else:
|
|
1289
|
+
denom = max(neg_width, 1e-12)
|
|
1290
|
+
return _interpolate_arm(distance / denom, neg_stops)
|
|
1291
|
+
else:
|
|
1292
|
+
distance = clamped - hinge
|
|
1293
|
+
if arm_mode == "asymmetric":
|
|
1294
|
+
denom = max(neg_width, pos_width, 1e-12)
|
|
1295
|
+
else:
|
|
1296
|
+
denom = max(pos_width, 1e-12)
|
|
1297
|
+
return _interpolate_arm(distance / denom, pos_stops)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def compute_scale_domain(
|
|
1301
|
+
data: list[dict[str, Any]], field: str, cfg: ScaleTargetConfig
|
|
1302
|
+
) -> tuple[float, float]:
|
|
1303
|
+
"""Compute the effective [min, max] domain for a scale target.
|
|
1304
|
+
|
|
1305
|
+
Uses explicit overrides when set, otherwise infers from data.
|
|
1306
|
+
Falls back to (0, 1) when no numeric values are present.
|
|
1307
|
+
"""
|
|
1308
|
+
|
|
1309
|
+
has_min = cfg.min is not None
|
|
1310
|
+
has_max = cfg.max is not None
|
|
1311
|
+
|
|
1312
|
+
if has_min and has_max:
|
|
1313
|
+
return (float(cfg.min), float(cfg.max)) # type: ignore[arg-type]
|
|
1314
|
+
|
|
1315
|
+
values: list[float] = []
|
|
1316
|
+
for row in data:
|
|
1317
|
+
coerced = coerce_numeric(row.get(field))
|
|
1318
|
+
if coerced is not None:
|
|
1319
|
+
values.append(coerced)
|
|
1320
|
+
|
|
1321
|
+
if not values:
|
|
1322
|
+
return (
|
|
1323
|
+
float(cfg.min) if has_min else 0.0, # type: ignore[arg-type]
|
|
1324
|
+
float(cfg.max) if has_max else 1.0, # type: ignore[arg-type]
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
lo = float(cfg.min) if has_min else min(values) # type: ignore[arg-type]
|
|
1328
|
+
hi = float(cfg.max) if has_max else max(values) # type: ignore[arg-type]
|
|
1329
|
+
return (lo, hi)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def resolve_cell_conditional_styles(
|
|
1333
|
+
col_config: TableColumnConfig,
|
|
1334
|
+
value: Any,
|
|
1335
|
+
data: list[dict[str, Any]],
|
|
1336
|
+
when_rules: Sequence[ConditionalRule] | None = None,
|
|
1337
|
+
col_format: str | None = None,
|
|
1338
|
+
row_role: str = "value",
|
|
1339
|
+
col_name: str = "",
|
|
1340
|
+
) -> tuple[str | None, str | None, str | float | None, str | None, str | None]:
|
|
1341
|
+
"""Resolve effective cell styling.
|
|
1342
|
+
|
|
1343
|
+
Precedence (each layer only overrides keys it sets):
|
|
1344
|
+
1. base column style
|
|
1345
|
+
2. scale — continuous numeric mapping (skipped for summary/total rows)
|
|
1346
|
+
3. when — discrete predicate rules from the chart-level
|
|
1347
|
+
``conditional_formatting`` block (``when_rules``)
|
|
1348
|
+
|
|
1349
|
+
Args:
|
|
1350
|
+
col_config: Column configuration carrying base style and scale config.
|
|
1351
|
+
value: Raw cell value (may be a numeric string from CSV loads).
|
|
1352
|
+
data: Rows used to compute the scale domain. Callers must exclude
|
|
1353
|
+
summary/total rows so the domain is not skewed by aggregate values.
|
|
1354
|
+
when_rules: Author-specified threshold rules. Applied to all row roles.
|
|
1355
|
+
col_format: D3 format string for the column, passed to ``resolve_hinge``
|
|
1356
|
+
so the "auto" branch can detect percent-format domains.
|
|
1357
|
+
row_role: Semantic role of this row — ``"value"``, ``"summary"``, or
|
|
1358
|
+
``"total"``. Scale (heatmap) fills are skipped for non-value rows
|
|
1359
|
+
because coloring aggregate rows by their own value relative to the
|
|
1360
|
+
column domain is visually misleading. ``when`` rules still apply.
|
|
1361
|
+
|
|
1362
|
+
Returns ``(background, color, weight, style, decoration)``.
|
|
1363
|
+
``weight`` is the raw ``FontStyle.weight`` value — ``str | float | None``
|
|
1364
|
+
— left for callers to normalize via ``font_weight_as_css`` before CSS
|
|
1365
|
+
emission.
|
|
1366
|
+
"""
|
|
1367
|
+
bg = col_config.background
|
|
1368
|
+
col_font = col_config.font
|
|
1369
|
+
color = col_font.color if col_font is not None else None
|
|
1370
|
+
fw = col_font.weight if col_font is not None else None
|
|
1371
|
+
fstyle: str | None = None
|
|
1372
|
+
fdecoration: str | None = None
|
|
1373
|
+
|
|
1374
|
+
# Layer 2: scale — skipped for summary/total rows.
|
|
1375
|
+
if col_config.scale and not is_summary_role(row_role):
|
|
1376
|
+
scale = col_config.scale
|
|
1377
|
+
for attr, target in [("background", scale.background), ("color", scale.color)]:
|
|
1378
|
+
if target is None:
|
|
1379
|
+
continue
|
|
1380
|
+
if value is None:
|
|
1381
|
+
if target.null_color is not None:
|
|
1382
|
+
if attr == "background":
|
|
1383
|
+
bg = target.null_color
|
|
1384
|
+
else:
|
|
1385
|
+
color = target.null_color
|
|
1386
|
+
elif (numeric_value := coerce_numeric(value)) is not None:
|
|
1387
|
+
lo, hi = compute_scale_domain(data, col_name, target)
|
|
1388
|
+
palette = resolve_palette_stops(target.palette, surface="table")
|
|
1389
|
+
hinge = resolve_hinge(target, lo, hi, col_format)
|
|
1390
|
+
scaled = interpolate_scale_color(
|
|
1391
|
+
numeric_value,
|
|
1392
|
+
lo,
|
|
1393
|
+
hi,
|
|
1394
|
+
palette,
|
|
1395
|
+
hinge=hinge,
|
|
1396
|
+
arm_mode=target.arm_mode,
|
|
1397
|
+
)
|
|
1398
|
+
if attr == "background":
|
|
1399
|
+
bg = scaled
|
|
1400
|
+
else:
|
|
1401
|
+
color = scaled
|
|
1402
|
+
|
|
1403
|
+
# Layer 3: when rules from the chart-level conditional_formatting block.
|
|
1404
|
+
if when_rules:
|
|
1405
|
+
overrides = resolve_conditional_styles(when_rules, value)
|
|
1406
|
+
if "background" in overrides:
|
|
1407
|
+
bg = overrides["background"]
|
|
1408
|
+
if "color" in overrides:
|
|
1409
|
+
color = overrides["color"]
|
|
1410
|
+
if "weight" in overrides:
|
|
1411
|
+
fw = overrides["weight"]
|
|
1412
|
+
if "style" in overrides:
|
|
1413
|
+
fstyle = overrides["style"]
|
|
1414
|
+
if "decoration" in overrides:
|
|
1415
|
+
fdecoration = overrides["decoration"]
|
|
1416
|
+
|
|
1417
|
+
return bg, color, fw, fstyle, fdecoration
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def resolve_cell_glyph(
|
|
1421
|
+
col_config: TableColumnConfig | None,
|
|
1422
|
+
value: Any,
|
|
1423
|
+
when_rules: Sequence[ConditionalRule] | None,
|
|
1424
|
+
) -> tuple[str | None, str | None]:
|
|
1425
|
+
"""Resolve the effective glyph + glyph_color for a single cell.
|
|
1426
|
+
|
|
1427
|
+
A column's static ``glyph`` is the base; a matching ``when`` rule's
|
|
1428
|
+
``glyph`` overrides it. When a rule sets ``glyph`` but not
|
|
1429
|
+
``glyph_color``, the cell falls back to default ink rather than
|
|
1430
|
+
inheriting the static or earlier-rule color (see
|
|
1431
|
+
``_apply_rule_outputs``).
|
|
1432
|
+
"""
|
|
1433
|
+
return resolve_cell_glyph_from_overrides(
|
|
1434
|
+
col_config,
|
|
1435
|
+
resolve_conditional_styles(when_rules, value) if when_rules else {},
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def resolve_cell_glyph_from_overrides(
|
|
1440
|
+
col_config: TableColumnConfig | None,
|
|
1441
|
+
overrides: dict[str, Any],
|
|
1442
|
+
) -> tuple[str | None, str | None]:
|
|
1443
|
+
"""Variant that reads from an already-merged ``overrides`` dict.
|
|
1444
|
+
|
|
1445
|
+
Lets callers that already evaluated ``resolve_conditional_styles``
|
|
1446
|
+
(e.g. the table renderer's per-cell loop) avoid a second pass.
|
|
1447
|
+
"""
|
|
1448
|
+
if "glyph" in overrides:
|
|
1449
|
+
return overrides["glyph"], overrides.get("glyph_color")
|
|
1450
|
+
if col_config is not None and col_config.glyph is not None:
|
|
1451
|
+
return col_config.glyph, col_config.glyph_color
|
|
1452
|
+
return None, None
|