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,809 @@
|
|
|
1
|
+
"""Predicate-based time-unit detection and smart-default labelExpr for temporal axes.
|
|
2
|
+
|
|
3
|
+
detect_time_unit: pure function; given distinct non-null x-field values,
|
|
4
|
+
returns the VL timeUnit string or None (continuous/sub-daily).
|
|
5
|
+
|
|
6
|
+
default_label_expr_for: returns a Vega expression for an
|
|
7
|
+
encoding time unit + label time unit pair.
|
|
8
|
+
|
|
9
|
+
Bucket string vocabulary (pattern → VL timeUnit):
|
|
10
|
+
YYYY-MM → yearmonth (2024-01)
|
|
11
|
+
Mon YYYY → yearmonth (Jan 2024)
|
|
12
|
+
MM/YYYY → yearmonth (01/2024, US-only)
|
|
13
|
+
YYYY-Qn → yearquarter (2024-Q1, canonical ISO quarter)
|
|
14
|
+
Qn YYYY → yearquarter (Q1 2024)
|
|
15
|
+
YYYYQn → yearquarter (2024Q1)
|
|
16
|
+
FYnnnn → year (FY2024 → Jan 1)
|
|
17
|
+
MM/DD/YYYY → yearmonthdate (01/15/2024, US-only)
|
|
18
|
+
Mon DD[,] YYYY → yearmonthdate (Jan 15, 2024)
|
|
19
|
+
YYYY-Www → yearweek (2024-W01, canonical ISO week)
|
|
20
|
+
W[eek ]N YYYY → yearweek (W32 2024, Week 32 2024)
|
|
21
|
+
Half-year (H1 YYYY, YYYY-H1): not supported; treated as unparseable.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import datetime as dt
|
|
27
|
+
import itertools
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
# ISO date: "2024-01-15"
|
|
32
|
+
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
33
|
+
# ISO datetime with T separator ("2024-01-15T14:30:00") or space from DB driver str()
|
|
34
|
+
_ISO_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]")
|
|
35
|
+
# ISO week-year: "2024-W32" (weeks 01–53)
|
|
36
|
+
_ISO_WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$")
|
|
37
|
+
# Calendar quarter: "2024-Q3"
|
|
38
|
+
_ISO_QUARTER_RE = re.compile(r"^\d{4}-Q[1-4]$")
|
|
39
|
+
|
|
40
|
+
# ── Bucket-string patterns ──────────────────────────────────────────────────
|
|
41
|
+
# YYYY-MM: 2024-01 (valid month 01-12)
|
|
42
|
+
_YEARMONTH_STR_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
|
43
|
+
# Mon YYYY: Jan 2024
|
|
44
|
+
_MON_YYYY_RE = re.compile(
|
|
45
|
+
r"^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})$", re.IGNORECASE
|
|
46
|
+
)
|
|
47
|
+
# MM/YYYY: 01/2024 (US month/year)
|
|
48
|
+
_MM_YYYY_RE = re.compile(r"^(0[1-9]|1[0-2])/(\d{4})$")
|
|
49
|
+
# Qn YYYY: Q1 2024 (space optional)
|
|
50
|
+
_Q_YYYY_RE = re.compile(r"^Q([1-4])\s*(\d{4})$", re.IGNORECASE)
|
|
51
|
+
# YYYYQn: 2024Q1
|
|
52
|
+
_YYYY_Q_RE = re.compile(r"^(\d{4})Q([1-4])$", re.IGNORECASE)
|
|
53
|
+
# FYnnnn: FY2024
|
|
54
|
+
_FY_RE = re.compile(r"^FY(\d{4})$", re.IGNORECASE)
|
|
55
|
+
# MM/DD/YYYY: 01/15/2024 (US month/day/year)
|
|
56
|
+
_MM_DD_YYYY_RE = re.compile(r"^(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])/(\d{4})$")
|
|
57
|
+
# Mon DD[,] YYYY: Jan 15, 2024 or Jan 15 2024
|
|
58
|
+
_MON_DD_YYYY_RE = re.compile(
|
|
59
|
+
r"^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),?\s+(\d{4})$",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
# W[eek ]N YYYY: W32 2024, Week 32 2024
|
|
63
|
+
_WEEK_SPELLED_RE = re.compile(r"^W(?:eek\s*)?(\d{1,2})\s+(\d{4})$", re.IGNORECASE)
|
|
64
|
+
|
|
65
|
+
_MONTH_ABBR: dict[str, int] = {
|
|
66
|
+
"jan": 1,
|
|
67
|
+
"feb": 2,
|
|
68
|
+
"mar": 3,
|
|
69
|
+
"apr": 4,
|
|
70
|
+
"may": 5,
|
|
71
|
+
"jun": 6,
|
|
72
|
+
"jul": 7,
|
|
73
|
+
"aug": 8,
|
|
74
|
+
"sep": 9,
|
|
75
|
+
"oct": 10,
|
|
76
|
+
"nov": 11,
|
|
77
|
+
"dec": 12,
|
|
78
|
+
}
|
|
79
|
+
_QUARTER_FIRST_MONTH_LOOKUP: dict[int, int] = {1: 1, 2: 4, 3: 7, 4: 10}
|
|
80
|
+
|
|
81
|
+
# Ordered family list: (name, regex). First match wins in _classify_bucket.
|
|
82
|
+
_BUCKET_FAMILIES: list[tuple[str, re.Pattern[str]]] = [
|
|
83
|
+
("iso_week", _ISO_WEEK_RE),
|
|
84
|
+
("iso_quarter", _ISO_QUARTER_RE),
|
|
85
|
+
("yearmonth_str", _YEARMONTH_STR_RE),
|
|
86
|
+
("mon_yyyy", _MON_YYYY_RE),
|
|
87
|
+
("mm_yyyy", _MM_YYYY_RE),
|
|
88
|
+
("q_yyyy", _Q_YYYY_RE),
|
|
89
|
+
("yyyy_q", _YYYY_Q_RE),
|
|
90
|
+
("fy", _FY_RE),
|
|
91
|
+
("mm_dd_yyyy", _MM_DD_YYYY_RE),
|
|
92
|
+
("mon_dd_yyyy", _MON_DD_YYYY_RE),
|
|
93
|
+
("week_spelled", _WEEK_SPELLED_RE),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _classify_bucket(v: str) -> str | None:
|
|
98
|
+
"""Return the format-family name for a bucket string, or None."""
|
|
99
|
+
for name, pattern in _BUCKET_FAMILIES:
|
|
100
|
+
if pattern.match(v):
|
|
101
|
+
return name
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_bucket_string(value: str) -> dt.date | None:
|
|
106
|
+
"""Return the anchor date (first instant of bucket) for a labeled format.
|
|
107
|
+
|
|
108
|
+
Returns None for unrecognized strings. Does NOT raise — invalid ISO weeks
|
|
109
|
+
that pass the regex (e.g. W53 in a 52-week year) return None here; the
|
|
110
|
+
error is raised only by the explicit ``_week_to_iso`` converter used in
|
|
111
|
+
``normalize_labeled_temporal``.
|
|
112
|
+
"""
|
|
113
|
+
if _YEARMONTH_STR_RE.match(value):
|
|
114
|
+
return dt.date(int(value[:4]), int(value[5:7]), 1)
|
|
115
|
+
m = _MON_YYYY_RE.match(value)
|
|
116
|
+
if m:
|
|
117
|
+
month = _MONTH_ABBR[m.group(1).lower()]
|
|
118
|
+
return dt.date(int(m.group(2)), month, 1)
|
|
119
|
+
m = _MM_YYYY_RE.match(value)
|
|
120
|
+
if m:
|
|
121
|
+
return dt.date(int(m.group(2)), int(m.group(1)), 1)
|
|
122
|
+
m = _Q_YYYY_RE.match(value)
|
|
123
|
+
if m:
|
|
124
|
+
return dt.date(int(m.group(2)), _QUARTER_FIRST_MONTH_LOOKUP[int(m.group(1))], 1)
|
|
125
|
+
m = _YYYY_Q_RE.match(value)
|
|
126
|
+
if m:
|
|
127
|
+
return dt.date(int(m.group(1)), _QUARTER_FIRST_MONTH_LOOKUP[int(m.group(2))], 1)
|
|
128
|
+
m = _FY_RE.match(value)
|
|
129
|
+
if m:
|
|
130
|
+
return dt.date(int(m.group(1)), 1, 1)
|
|
131
|
+
m = _MM_DD_YYYY_RE.match(value)
|
|
132
|
+
if m:
|
|
133
|
+
try:
|
|
134
|
+
return dt.date(int(m.group(3)), int(m.group(1)), int(m.group(2)))
|
|
135
|
+
except ValueError:
|
|
136
|
+
return None
|
|
137
|
+
m = _MON_DD_YYYY_RE.match(value)
|
|
138
|
+
if m:
|
|
139
|
+
month = _MONTH_ABBR[m.group(1).lower()]
|
|
140
|
+
try:
|
|
141
|
+
return dt.date(int(m.group(3)), month, int(m.group(2)))
|
|
142
|
+
except ValueError:
|
|
143
|
+
return None
|
|
144
|
+
m = _WEEK_SPELLED_RE.match(value)
|
|
145
|
+
if m:
|
|
146
|
+
week, year = int(m.group(1)), int(m.group(2))
|
|
147
|
+
try:
|
|
148
|
+
return dt.date.fromisocalendar(year, week, 1)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
# ISO week/quarter: delegate to existing helpers (avoid duplication);
|
|
152
|
+
# return None on invalid week numbers (the convert path raises separately).
|
|
153
|
+
if _ISO_WEEK_RE.match(value):
|
|
154
|
+
year, week = int(value[:4]), int(value[6:])
|
|
155
|
+
try:
|
|
156
|
+
return dt.date.fromisocalendar(year, week, 1)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return None
|
|
159
|
+
if _ISO_QUARTER_RE.match(value):
|
|
160
|
+
q = int(value[6])
|
|
161
|
+
return dt.date(int(value[:4]), _QUARTER_FIRST_MONTH_LOOKUP[q], 1)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_TIME_UNIT_TO_VL: dict[str, str] = {
|
|
166
|
+
"monthofyear": "month",
|
|
167
|
+
"dayofweek": "day",
|
|
168
|
+
"dayofmonth": "date",
|
|
169
|
+
"dayofyear": "dayofyear",
|
|
170
|
+
"hourofday": "hours",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Calendar-bucketed units → default ordinal scale.
|
|
174
|
+
# Distinct from time-part units (monthofyear etc.) which stay temporal.
|
|
175
|
+
BUCKETED_CALENDAR_UNITS: frozenset[str] = frozenset(
|
|
176
|
+
{"year", "yearquarter", "yearmonth", "yearweek", "yearmonthdate"}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_date(value: Any) -> dt.date | dt.datetime | None:
|
|
181
|
+
"""Parse value to date/datetime. Returns None if unparseable."""
|
|
182
|
+
if isinstance(value, dt.datetime):
|
|
183
|
+
return value
|
|
184
|
+
if isinstance(value, dt.date):
|
|
185
|
+
return value
|
|
186
|
+
if isinstance(value, str):
|
|
187
|
+
if _ISO_DATETIME_RE.match(value):
|
|
188
|
+
try:
|
|
189
|
+
# Python <3.11: fromisoformat doesn't accept the trailing 'Z' UTC suffix.
|
|
190
|
+
v = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
191
|
+
return dt.datetime.fromisoformat(v)
|
|
192
|
+
except ValueError:
|
|
193
|
+
return None
|
|
194
|
+
if _ISO_DATE_RE.match(value):
|
|
195
|
+
try:
|
|
196
|
+
return dt.date.fromisoformat(value)
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|
|
199
|
+
return _parse_bucket_string(value)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def detect_time_unit(values: list[Any]) -> str | None:
|
|
204
|
+
"""Detect VL timeUnit from distinct non-null x-field values.
|
|
205
|
+
|
|
206
|
+
Returns one of: "year", "yearquarter", "yearmonth", "yearweek",
|
|
207
|
+
"yearmonthdate", or None (sub-daily continuous or insufficient data).
|
|
208
|
+
|
|
209
|
+
Raises ValueError when ≥10% of distinct values are unparseable strings.
|
|
210
|
+
"""
|
|
211
|
+
distinct = list({v for v in values if v is not None})
|
|
212
|
+
if len(distinct) < 2:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
parsed: list[dt.date | dt.datetime] = []
|
|
216
|
+
bad: list[Any] = []
|
|
217
|
+
for v in distinct:
|
|
218
|
+
p = _parse_date(v)
|
|
219
|
+
if p is None:
|
|
220
|
+
bad.append(v)
|
|
221
|
+
else:
|
|
222
|
+
parsed.append(p)
|
|
223
|
+
|
|
224
|
+
if bad and len(bad) / len(distinct) >= 0.1:
|
|
225
|
+
examples = bad[:5]
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Couldn't auto-detect timeUnit: ≥10% unparseable date values: {examples}. "
|
|
228
|
+
"Set style.axis_x.time_unit explicitly or fix the query."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if not parsed or len(parsed) < 2:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
# Sub-daily fallthrough: any nonzero hms → continuous
|
|
235
|
+
for p in parsed:
|
|
236
|
+
if isinstance(p, dt.datetime) and (p.hour or p.minute or p.second):
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
# Normalize to date for predicate checks
|
|
240
|
+
dates = [p.date() if isinstance(p, dt.datetime) else p for p in parsed]
|
|
241
|
+
|
|
242
|
+
# Predicate check: coarsest to finest
|
|
243
|
+
if all(d.month == 1 and d.day == 1 for d in dates):
|
|
244
|
+
return "year"
|
|
245
|
+
if all(d.day == 1 and d.month in (1, 4, 7, 10) for d in dates):
|
|
246
|
+
return "yearquarter"
|
|
247
|
+
if all(d.day == 1 for d in dates):
|
|
248
|
+
return "yearmonth"
|
|
249
|
+
# Weekly cadence: every distinct value falls on the same weekday. Pinning
|
|
250
|
+
# to Monday-only (ISO week-start) missed the common case of Sunday-
|
|
251
|
+
# aligned weekly data (and Sat / mid-week pay-period reports), all of
|
|
252
|
+
# which want yearmonth-style x-axis labels rather than per-day "7 Jan,
|
|
253
|
+
# 14 Jan, ..." chatter. Same-weekday already implies every pairwise gap
|
|
254
|
+
# is a multiple of 7 (calendar arithmetic), so no separate gap check.
|
|
255
|
+
if len({d.weekday() for d in dates}) == 1:
|
|
256
|
+
return "yearweek"
|
|
257
|
+
return "yearmonthdate"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
_MIXED_LABEL_MSG = (
|
|
261
|
+
"Couldn't auto-detect timeUnit: column contains mixed label and "
|
|
262
|
+
"non-label values. "
|
|
263
|
+
"Set style.axis_x.time_unit explicitly."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _week_to_iso(v: str) -> str:
|
|
268
|
+
year, week = int(v[:4]), int(v[6:])
|
|
269
|
+
try:
|
|
270
|
+
return dt.date.fromisocalendar(year, week, 1).isoformat()
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"Couldn't auto-detect timeUnit: '{v}' is not a valid ISO week "
|
|
274
|
+
f"(week {week} does not exist in year {year}). "
|
|
275
|
+
"Set style.axis_x.time_unit explicitly or fix the query."
|
|
276
|
+
) from exc
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _quarter_to_iso(v: str) -> str:
|
|
280
|
+
return dt.date(int(v[:4]), _QUARTER_FIRST_MONTH_LOOKUP[int(v[6])], 1).isoformat()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _bucket_to_iso(v: str) -> str:
|
|
284
|
+
"""Convert any recognized bucket string to an ISO date string."""
|
|
285
|
+
if _ISO_WEEK_RE.match(v):
|
|
286
|
+
return _week_to_iso(v)
|
|
287
|
+
if _ISO_QUARTER_RE.match(v):
|
|
288
|
+
return _quarter_to_iso(v)
|
|
289
|
+
anchor = _parse_bucket_string(v)
|
|
290
|
+
if anchor is None:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"Couldn't auto-detect timeUnit: unrecognized bucket format '{v}'. "
|
|
293
|
+
"Set style.axis_x.time_unit explicitly or fix the query."
|
|
294
|
+
)
|
|
295
|
+
return anchor.isoformat()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def normalize_labeled_temporal(
|
|
299
|
+
data: list[dict[str, Any]], field: str
|
|
300
|
+
) -> list[dict[str, Any]]:
|
|
301
|
+
"""Convert labeled bucket strings in *field* to ISO dates.
|
|
302
|
+
|
|
303
|
+
Supported formats: YYYY-Www, YYYY-Qn, Qn YYYY, YYYYQn, YYYY-MM,
|
|
304
|
+
Mon YYYY, MM/YYYY, FYnnnn, MM/DD/YYYY, Mon DD YYYY, W[eek ]N YYYY.
|
|
305
|
+
|
|
306
|
+
When all non-null values share the same format family, returns a copy
|
|
307
|
+
with values replaced by the ISO date for the first instant of each
|
|
308
|
+
bucket. Returns *data* unchanged when the field uses plain ISO dates
|
|
309
|
+
or date/datetime objects (no labeling needed).
|
|
310
|
+
|
|
311
|
+
Raises ValueError when:
|
|
312
|
+
- labeled values are mixed with ISO date strings (or other non-labeled)
|
|
313
|
+
- values span more than one format family (e.g. YYYY-Www with YYYY-Qn)
|
|
314
|
+
- an ISO week label encodes an invalid week number
|
|
315
|
+
"""
|
|
316
|
+
values = [row[field] for row in data if field in row and row[field] is not None]
|
|
317
|
+
if not values:
|
|
318
|
+
return data
|
|
319
|
+
|
|
320
|
+
def _is_iso(v: Any) -> bool:
|
|
321
|
+
return isinstance(v, str) and bool(
|
|
322
|
+
_ISO_DATE_RE.match(v) or _ISO_DATETIME_RE.match(v)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _is_labeled(v: Any) -> bool:
|
|
326
|
+
return isinstance(v, str) and not _is_iso(v) and _classify_bucket(v) is not None
|
|
327
|
+
|
|
328
|
+
labeled = [v for v in values if _is_labeled(v)]
|
|
329
|
+
if not labeled:
|
|
330
|
+
return data
|
|
331
|
+
|
|
332
|
+
# Any ISO dates mixed in → mixed label error
|
|
333
|
+
if any(_is_iso(v) for v in values):
|
|
334
|
+
raise ValueError(_MIXED_LABEL_MSG)
|
|
335
|
+
|
|
336
|
+
# Any unrecognized strings mixed in → mixed label error
|
|
337
|
+
if len(labeled) < len(values):
|
|
338
|
+
raise ValueError(_MIXED_LABEL_MSG)
|
|
339
|
+
|
|
340
|
+
# All are labeled — check they're all the same format family
|
|
341
|
+
families = {_classify_bucket(v) for v in labeled}
|
|
342
|
+
if len(families) > 1:
|
|
343
|
+
raise ValueError(_MIXED_LABEL_MSG)
|
|
344
|
+
|
|
345
|
+
return [
|
|
346
|
+
(
|
|
347
|
+
{**row, field: _bucket_to_iso(row[field])}
|
|
348
|
+
if field in row and row[field] is not None
|
|
349
|
+
else row
|
|
350
|
+
)
|
|
351
|
+
for row in data
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def ordinal_axis_values(
|
|
356
|
+
data: list[dict[str, Any]],
|
|
357
|
+
field: str,
|
|
358
|
+
time_unit: str,
|
|
359
|
+
label_time_unit: str | None,
|
|
360
|
+
) -> list[Any] | None:
|
|
361
|
+
"""Return sorted distinct x-values filtered to the label cadence.
|
|
362
|
+
|
|
363
|
+
For ordinal bucketed-time axes, returns the sorted distinct values from
|
|
364
|
+
data. When label_time_unit is coarser than encoding time_unit, filters
|
|
365
|
+
to values that open a new label period (e.g., first week of each month
|
|
366
|
+
for yearweek encoding with yearmonth labels).
|
|
367
|
+
|
|
368
|
+
Returns None when the field has no non-null values.
|
|
369
|
+
"""
|
|
370
|
+
values = sorted(
|
|
371
|
+
{row[field] for row in data if field in row and row[field] is not None}
|
|
372
|
+
)
|
|
373
|
+
if not values:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
# axis.values rides directly into the VL spec; stringify date/datetime so
|
|
377
|
+
# vl_convert always receives JSON-safe values regardless of whether the
|
|
378
|
+
# caller (e.g. the per-layer-datasets path) normalized rows beforehand.
|
|
379
|
+
values = [
|
|
380
|
+
v.isoformat() if isinstance(v, (dt.date, dt.datetime)) else v for v in values
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
if not label_time_unit or label_time_unit in ("auto", "none"):
|
|
384
|
+
return values
|
|
385
|
+
|
|
386
|
+
# Filter to label-period openers only.
|
|
387
|
+
# Values at this point are ISO date strings (from normalize_data_types
|
|
388
|
+
# or normalize_labeled_temporal) so we parse them to dates.
|
|
389
|
+
filtered: list[Any] = []
|
|
390
|
+
for v in values:
|
|
391
|
+
d = _parse_date(v)
|
|
392
|
+
if d is None:
|
|
393
|
+
# Non-parseable string → include (conservative)
|
|
394
|
+
filtered.append(v)
|
|
395
|
+
continue
|
|
396
|
+
date = d.date() if isinstance(d, dt.datetime) else d
|
|
397
|
+
if _is_label_opener(date, time_unit, label_time_unit):
|
|
398
|
+
filtered.append(v)
|
|
399
|
+
return filtered if filtered else values
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _next_bucket(date: dt.date, time_unit: str) -> dt.date:
|
|
403
|
+
"""Return the first date of the next bucket at the given grain."""
|
|
404
|
+
if time_unit == "yearmonthdate":
|
|
405
|
+
return date + dt.timedelta(days=1)
|
|
406
|
+
if time_unit == "yearweek":
|
|
407
|
+
return date + dt.timedelta(weeks=1)
|
|
408
|
+
if time_unit == "yearmonth":
|
|
409
|
+
# Advance to first of the next month
|
|
410
|
+
if date.month == 12:
|
|
411
|
+
return dt.date(date.year + 1, 1, 1)
|
|
412
|
+
return dt.date(date.year, date.month + 1, 1)
|
|
413
|
+
if time_unit == "yearquarter":
|
|
414
|
+
# Advance by 3 months
|
|
415
|
+
new_month = date.month + 3
|
|
416
|
+
if new_month > 12:
|
|
417
|
+
return dt.date(date.year + 1, new_month - 12, 1)
|
|
418
|
+
return dt.date(date.year, new_month, 1)
|
|
419
|
+
if time_unit == "year":
|
|
420
|
+
return dt.date(date.year + 1, 1, 1)
|
|
421
|
+
raise ValueError(f"Unsupported time_unit for bucket stepping: {time_unit!r}")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _enumerate_buckets(
|
|
425
|
+
min_date: dt.date, max_date: dt.date, time_unit: str
|
|
426
|
+
) -> list[dt.date]:
|
|
427
|
+
"""Return every bucket date from min_date to max_date inclusive."""
|
|
428
|
+
buckets: list[dt.date] = []
|
|
429
|
+
current = min_date
|
|
430
|
+
while current <= max_date:
|
|
431
|
+
buckets.append(current)
|
|
432
|
+
current = _next_bucket(current, time_unit)
|
|
433
|
+
return buckets
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
_GAP_FILL_HANDLINGS = frozenset(
|
|
437
|
+
{
|
|
438
|
+
"linear",
|
|
439
|
+
"step-after",
|
|
440
|
+
"step-before",
|
|
441
|
+
"step-center",
|
|
442
|
+
"curve",
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _smoothstep(t: float) -> float:
|
|
448
|
+
"""Hermite ease: 0 at t=0, 1 at t=1, flat derivatives at endpoints."""
|
|
449
|
+
return t * t * (3.0 - 2.0 * t)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _apply_gap_fill_handling(
|
|
453
|
+
rows: list[dict[str, Any]],
|
|
454
|
+
x_field: str,
|
|
455
|
+
dim_fields: list[str],
|
|
456
|
+
dim_combos: list[tuple[Any, ...]],
|
|
457
|
+
measure_cols: list[str],
|
|
458
|
+
mode: str,
|
|
459
|
+
) -> None:
|
|
460
|
+
"""Fill null measures on synthetic buckets; mutates ``rows`` in place."""
|
|
461
|
+
key_to_row: dict[tuple[Any, ...], dict[str, Any]] = {}
|
|
462
|
+
bucket_order: dict[str, int] = {}
|
|
463
|
+
for row in rows:
|
|
464
|
+
bkt = row[x_field]
|
|
465
|
+
if bkt not in bucket_order:
|
|
466
|
+
bucket_order[bkt] = len(bucket_order)
|
|
467
|
+
combo = tuple(row.get(d) for d in dim_fields)
|
|
468
|
+
key_to_row[(bkt, *combo)] = row
|
|
469
|
+
|
|
470
|
+
all_buckets_sorted = sorted(bucket_order, key=lambda b: bucket_order[b])
|
|
471
|
+
|
|
472
|
+
for combo in dim_combos:
|
|
473
|
+
group_rows: list[dict[str, Any]] = []
|
|
474
|
+
for bkt in all_buckets_sorted:
|
|
475
|
+
key = (bkt, *combo)
|
|
476
|
+
if key in key_to_row:
|
|
477
|
+
group_rows.append(key_to_row[key])
|
|
478
|
+
|
|
479
|
+
for col in measure_cols:
|
|
480
|
+
n = len(group_rows)
|
|
481
|
+
# Only observed (query) rows are anchors — not values filled in this pass.
|
|
482
|
+
# Any non-null value is a valid anchor; arithmetic below raises loudly if
|
|
483
|
+
# the value is non-numeric (e.g. a string) rather than silently skipping it.
|
|
484
|
+
anchors = [idx for idx in range(n) if group_rows[idx][col] is not None]
|
|
485
|
+
for i, row in enumerate(group_rows):
|
|
486
|
+
if row[col] is not None:
|
|
487
|
+
continue
|
|
488
|
+
left_anchors = [a for a in anchors if a < i]
|
|
489
|
+
right_anchors = [a for a in anchors if a > i]
|
|
490
|
+
left_idx = left_anchors[-1] if left_anchors else -1
|
|
491
|
+
right_idx = right_anchors[0] if right_anchors else -1
|
|
492
|
+
|
|
493
|
+
if mode == "step-after":
|
|
494
|
+
if left_idx >= 0:
|
|
495
|
+
row[col] = group_rows[left_idx][col]
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
if mode == "step-before":
|
|
499
|
+
if right_idx >= 0:
|
|
500
|
+
row[col] = group_rows[right_idx][col]
|
|
501
|
+
continue
|
|
502
|
+
|
|
503
|
+
if left_idx < 0 or right_idx < 0:
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
left_val = group_rows[left_idx][col]
|
|
507
|
+
right_val = group_rows[right_idx][col]
|
|
508
|
+
gap_width = right_idx - left_idx
|
|
509
|
+
gap_pos = i - left_idx
|
|
510
|
+
t = gap_pos / gap_width
|
|
511
|
+
|
|
512
|
+
if mode == "linear":
|
|
513
|
+
row[col] = left_val + (right_val - left_val) * t
|
|
514
|
+
elif mode == "step-center":
|
|
515
|
+
mid = left_idx + gap_width / 2
|
|
516
|
+
row[col] = left_val if i < mid else right_val
|
|
517
|
+
elif mode == "curve":
|
|
518
|
+
row[col] = left_val + (right_val - left_val) * _smoothstep(t)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def complete_ordinal_time_series(
|
|
522
|
+
data: list[dict[str, Any]],
|
|
523
|
+
x_field: str,
|
|
524
|
+
time_unit: str,
|
|
525
|
+
dim_fields: list[str],
|
|
526
|
+
fill: str,
|
|
527
|
+
) -> list[dict[str, Any]]:
|
|
528
|
+
"""Synthesize missing time-bucket rows so every bucket in [min, max] is present.
|
|
529
|
+
|
|
530
|
+
For ordinal bucketed-time charts the engine must supply every bucket
|
|
531
|
+
between the dataset's min and max so the ordinal x-axis has a slot for
|
|
532
|
+
each period. Without this, missing buckets simply disappear from the axis.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
data: rows from the query (non-empty; caller must guard empty datasets).
|
|
536
|
+
x_field: the x-encoding column name (must be ISO date strings or
|
|
537
|
+
datetime.date objects after normalize_labeled_temporal runs).
|
|
538
|
+
time_unit: one of BUCKETED_CALENDAR_UNITS (year, yearquarter, yearmonth,
|
|
539
|
+
yearweek, yearmonthdate).
|
|
540
|
+
dim_fields: categorical dimension columns to cross-join over (e.g. the
|
|
541
|
+
color/series field). Engine only cross-joins over values actually
|
|
542
|
+
present in the data window.
|
|
543
|
+
fill: "null" fills missing measure columns with None;
|
|
544
|
+
"zero" fills with 0; interpolate-* modes fill interior synthetic
|
|
545
|
+
buckets (see ``_GAP_FILL_HANDLINGS``).
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
A new list of dicts, sorted (bucket asc, dim_1 asc, …), with the
|
|
549
|
+
original rows merged in. When no buckets are missing, returns data
|
|
550
|
+
sorted by the same key. If data is empty, returns data unchanged.
|
|
551
|
+
"""
|
|
552
|
+
if not data:
|
|
553
|
+
return data
|
|
554
|
+
|
|
555
|
+
# Collect all x values; parse to date for comparison
|
|
556
|
+
raw_x_values = [
|
|
557
|
+
row[x_field] for row in data if x_field in row and row[x_field] is not None
|
|
558
|
+
]
|
|
559
|
+
if not raw_x_values:
|
|
560
|
+
return data
|
|
561
|
+
|
|
562
|
+
# Convert to ISO strings for uniform comparison (ordinal path uses strings)
|
|
563
|
+
def _to_iso(v: Any) -> str:
|
|
564
|
+
if isinstance(v, dt.datetime):
|
|
565
|
+
return v.date().isoformat()
|
|
566
|
+
if isinstance(v, dt.date):
|
|
567
|
+
return v.isoformat()
|
|
568
|
+
return str(v)
|
|
569
|
+
|
|
570
|
+
x_iso = [_to_iso(v) for v in raw_x_values]
|
|
571
|
+
|
|
572
|
+
# Parse to dates for arithmetic
|
|
573
|
+
parsed_dates: list[dt.date] = []
|
|
574
|
+
for iso in x_iso:
|
|
575
|
+
d = _parse_date(iso)
|
|
576
|
+
if d is not None:
|
|
577
|
+
parsed_dates.append(d.date() if isinstance(d, dt.datetime) else d)
|
|
578
|
+
|
|
579
|
+
if not parsed_dates:
|
|
580
|
+
return data
|
|
581
|
+
|
|
582
|
+
min_date = min(parsed_dates)
|
|
583
|
+
max_date = max(parsed_dates)
|
|
584
|
+
all_buckets = _enumerate_buckets(min_date, max_date, time_unit)
|
|
585
|
+
|
|
586
|
+
# Determine distinct dimension values from actual data
|
|
587
|
+
dim_values: list[list[Any]] = []
|
|
588
|
+
for dim in dim_fields:
|
|
589
|
+
seen: list[Any] = []
|
|
590
|
+
seen_set: set[Any] = set()
|
|
591
|
+
for row in data:
|
|
592
|
+
v = row.get(dim)
|
|
593
|
+
if v not in seen_set:
|
|
594
|
+
seen_set.add(v)
|
|
595
|
+
seen.append(v)
|
|
596
|
+
dim_values.append(sorted(seen, key=lambda x: (x is None, x)))
|
|
597
|
+
|
|
598
|
+
# Identify measure columns: all non-x, non-dim columns
|
|
599
|
+
sample_keys = list(data[0].keys())
|
|
600
|
+
dim_set = set(dim_fields) | {x_field}
|
|
601
|
+
measure_cols = [k for k in sample_keys if k not in dim_set]
|
|
602
|
+
|
|
603
|
+
# For fill modes other than "zero", synthesized rows start as None; the
|
|
604
|
+
# second pass (_apply_gap_fill_handling) overwrites them for interior gaps.
|
|
605
|
+
fill_value = 0 if fill == "zero" else None
|
|
606
|
+
|
|
607
|
+
# Build lookup from (bucket_str, *dim_vals) → row
|
|
608
|
+
def _row_key(row: dict[str, Any]) -> tuple[Any, ...]:
|
|
609
|
+
return (_to_iso(row.get(x_field)),) + tuple(row.get(d) for d in dim_fields)
|
|
610
|
+
|
|
611
|
+
existing: dict[tuple[Any, ...], dict[str, Any]] = {
|
|
612
|
+
_row_key(row): row for row in data
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
# Generate full scaffold via cross-product of buckets × dim combinations
|
|
616
|
+
if dim_fields:
|
|
617
|
+
dim_combos: list[tuple[Any, ...]] = list(itertools.product(*dim_values))
|
|
618
|
+
else:
|
|
619
|
+
dim_combos = [()]
|
|
620
|
+
|
|
621
|
+
result: list[dict[str, Any]] = []
|
|
622
|
+
for bucket in all_buckets:
|
|
623
|
+
bucket_str = bucket.isoformat()
|
|
624
|
+
for combo in dim_combos:
|
|
625
|
+
key = (bucket_str,) + combo
|
|
626
|
+
if key in existing:
|
|
627
|
+
result.append(existing[key])
|
|
628
|
+
else:
|
|
629
|
+
# Synthesize a row: bucket value + dim values + filled measures
|
|
630
|
+
synth: dict[str, Any] = {x_field: bucket_str}
|
|
631
|
+
for dim, val in zip(dim_fields, combo, strict=True):
|
|
632
|
+
synth[dim] = val
|
|
633
|
+
for col in measure_cols:
|
|
634
|
+
synth[col] = fill_value
|
|
635
|
+
result.append(synth)
|
|
636
|
+
|
|
637
|
+
if fill in _GAP_FILL_HANDLINGS:
|
|
638
|
+
_apply_gap_fill_handling(
|
|
639
|
+
result, x_field, dim_fields, dim_combos, measure_cols, fill
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
return result
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _is_label_opener(date: dt.date, encoding_unit: str, label_unit: str) -> bool:
|
|
646
|
+
"""Return True when *date* opens a new label period."""
|
|
647
|
+
if label_unit == "year":
|
|
648
|
+
if encoding_unit in {"yearweek", "yearmonthdate"}:
|
|
649
|
+
return date.month == 1 and date.day <= 7
|
|
650
|
+
return date.month == 1 and date.day == 1
|
|
651
|
+
if label_unit == "yearquarter":
|
|
652
|
+
if encoding_unit == "yearweek":
|
|
653
|
+
return date.month % 3 == 1 and date.day <= 7
|
|
654
|
+
if encoding_unit == "yearmonthdate":
|
|
655
|
+
return date.month % 3 == 1 and date.day == 1
|
|
656
|
+
return date.month % 3 == 1 and date.day == 1 # yearmonth
|
|
657
|
+
if label_unit == "yearmonth":
|
|
658
|
+
if encoding_unit == "yearweek":
|
|
659
|
+
return date.day <= 7
|
|
660
|
+
if encoding_unit == "yearmonthdate":
|
|
661
|
+
return date.day == 1
|
|
662
|
+
return True # same cadence or unknown — include all
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def vl_time_unit(time_unit: str) -> str:
|
|
666
|
+
"""Return the Vega-Lite timeUnit for a Dataface time_unit value.
|
|
667
|
+
|
|
668
|
+
Chronological grains return their UTC variant (utcyearmonth etc.) so VL
|
|
669
|
+
bucketing stays UTC-aligned regardless of the renderer's TZ. Time-part
|
|
670
|
+
units (monthofyear, dayofweek, hourofday, …) are cyclic and have no utc*
|
|
671
|
+
sibling — they pass through via _TIME_UNIT_TO_VL.
|
|
672
|
+
"""
|
|
673
|
+
if time_unit in BUCKETED_CALENDAR_UNITS:
|
|
674
|
+
return f"utc{time_unit}"
|
|
675
|
+
return _TIME_UNIT_TO_VL.get(time_unit, time_unit)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def resolve_label_time_unit(
|
|
679
|
+
encoding_time_unit: str | None, authored_label_time_unit: str | None
|
|
680
|
+
) -> str | None:
|
|
681
|
+
"""Return the label cadence for a resolved encoding time_unit.
|
|
682
|
+
|
|
683
|
+
``None``/``auto`` inherit from the encoding cadence, except weekly buckets
|
|
684
|
+
default to month labels so dense weekly axes read Jan/Feb/Mar instead of
|
|
685
|
+
W01/W02/W03. ``none`` disables Dataface's smart label expression.
|
|
686
|
+
"""
|
|
687
|
+
if authored_label_time_unit == "none":
|
|
688
|
+
return None
|
|
689
|
+
if authored_label_time_unit not in (None, "auto"):
|
|
690
|
+
return authored_label_time_unit
|
|
691
|
+
if encoding_time_unit in {"yearweek", "yearmonthdate"}:
|
|
692
|
+
return "yearmonth"
|
|
693
|
+
return encoding_time_unit
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _year_label(v: str, fmt: str, _month: str, _date: str) -> str:
|
|
697
|
+
return f"{fmt}({v}, '%Y')"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _month_label(v: str, fmt: str, month: str, _date: str) -> str:
|
|
701
|
+
return (
|
|
702
|
+
f"{month}({v}) === 0"
|
|
703
|
+
f" ? [{fmt}({v}, '%b'), {fmt}({v}, '%Y')]"
|
|
704
|
+
f" : {fmt}({v}, '%b')"
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _quarter_label(v: str, fmt: str, month: str, _date: str) -> str:
|
|
709
|
+
return (
|
|
710
|
+
f"{month}({v}) === 0"
|
|
711
|
+
f" ? ['Q' + (floor({month}({v})/3) + 1), {fmt}({v}, '%Y')]"
|
|
712
|
+
f" : 'Q' + (floor({month}({v})/3) + 1)"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _week_label(v: str, fmt: str, month: str, date: str) -> str:
|
|
717
|
+
return (
|
|
718
|
+
f"{date}({v}) <= 7 && {month}({v}) === 0"
|
|
719
|
+
f" ? [{fmt}({v}, 'W%V'), {fmt}({v}, '%Y')]"
|
|
720
|
+
f" : {fmt}({v}, 'W%V')"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _day_label(v: str, fmt: str, month: str, date: str) -> str:
|
|
725
|
+
return (
|
|
726
|
+
f"{date}({v}) === 1 && {month}({v}) === 0"
|
|
727
|
+
f" ? [{fmt}({v}, '%-d %b'), {fmt}({v}, '%Y')]"
|
|
728
|
+
f" : {fmt}({v}, '%-d %b')"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def opens_label_period(
|
|
733
|
+
encoding_time_unit: str,
|
|
734
|
+
label_time_unit: str,
|
|
735
|
+
v: str = "datum.value",
|
|
736
|
+
month: str = "month",
|
|
737
|
+
date: str = "date",
|
|
738
|
+
) -> str | None:
|
|
739
|
+
if label_time_unit == "yearquarter" and encoding_time_unit in {
|
|
740
|
+
"yearmonth",
|
|
741
|
+
"yearweek",
|
|
742
|
+
"yearmonthdate",
|
|
743
|
+
}:
|
|
744
|
+
clauses = [f"{month}({v}) % 3 === 0"]
|
|
745
|
+
if encoding_time_unit == "yearweek":
|
|
746
|
+
clauses.append(f"{date}({v}) <= 7")
|
|
747
|
+
elif encoding_time_unit == "yearmonthdate":
|
|
748
|
+
clauses.append(f"{date}({v}) === 1")
|
|
749
|
+
return " && ".join(clauses)
|
|
750
|
+
if label_time_unit == "yearmonth" and encoding_time_unit in {
|
|
751
|
+
"yearweek",
|
|
752
|
+
"yearmonthdate",
|
|
753
|
+
}:
|
|
754
|
+
return (
|
|
755
|
+
f"{date}({v}) <= 7"
|
|
756
|
+
if encoding_time_unit == "yearweek"
|
|
757
|
+
else f"{date}({v}) === 1"
|
|
758
|
+
)
|
|
759
|
+
if label_time_unit == "year" and encoding_time_unit not in {"year"}:
|
|
760
|
+
clauses = [f"{month}({v}) === 0"]
|
|
761
|
+
if encoding_time_unit in {"yearweek", "yearmonthdate"}:
|
|
762
|
+
clauses.append(
|
|
763
|
+
f"{date}({v}) <= 7"
|
|
764
|
+
if encoding_time_unit == "yearweek"
|
|
765
|
+
else f"{date}({v}) === 1"
|
|
766
|
+
)
|
|
767
|
+
return " && ".join(clauses)
|
|
768
|
+
return None
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def default_label_expr_for(
|
|
772
|
+
encoding_time_unit: str | None,
|
|
773
|
+
label_time_unit: str | None,
|
|
774
|
+
) -> str | None:
|
|
775
|
+
"""Return a smart Vega labelExpr for an encoding/label cadence pair.
|
|
776
|
+
|
|
777
|
+
The gate (``opens_label_period``) filters to label-period openers because
|
|
778
|
+
VL's tick cadence is geometry-driven and may overshoot the label cadence —
|
|
779
|
+
producing duplicate Q-labels when monthly ticks land inside a quarter.
|
|
780
|
+
|
|
781
|
+
Always emits ``toDate(datum.value)`` + ``utcFormat`` / ``utcmonth`` /
|
|
782
|
+
``utcdate``. Ordinal axes are string-domain (need ``toDate`` to parse);
|
|
783
|
+
temporal axes use ``scale.type: "utc"`` (so component extraction must
|
|
784
|
+
also be UTC, otherwise local-TZ ``month()`` shifts January UTC into
|
|
785
|
+
December local and the cadence gate stamps every tick blank). ``toDate``
|
|
786
|
+
is a no-op on Date values, so a single shape covers both paths without
|
|
787
|
+
local-TZ drift.
|
|
788
|
+
"""
|
|
789
|
+
if not encoding_time_unit or not label_time_unit:
|
|
790
|
+
return None
|
|
791
|
+
if label_time_unit in {"auto", "none"}:
|
|
792
|
+
return None
|
|
793
|
+
label_exprs = {
|
|
794
|
+
"year": _year_label,
|
|
795
|
+
"yearquarter": _quarter_label,
|
|
796
|
+
"yearmonth": _month_label,
|
|
797
|
+
"yearweek": _week_label,
|
|
798
|
+
"yearmonthdate": _day_label,
|
|
799
|
+
}
|
|
800
|
+
label_fn = label_exprs.get(label_time_unit)
|
|
801
|
+
if label_fn is None:
|
|
802
|
+
return None
|
|
803
|
+
v = "toDate(datum.value)"
|
|
804
|
+
fmt = "utcFormat"
|
|
805
|
+
month = "utcmonth"
|
|
806
|
+
date = "utcdate"
|
|
807
|
+
expr = label_fn(v, fmt, month, date)
|
|
808
|
+
gate = opens_label_period(encoding_time_unit, label_time_unit, v, month, date)
|
|
809
|
+
return f"{gate} ? ({expr}) : ''" if gate else expr
|