dataface 0.3.2.dev22__py3-none-any.whl → 0.3.2.dev111__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.
- dataface/__init__.py +70 -23
- dataface/agent_api/__init__.py +162 -68
- dataface/agent_api/_paths.py +9 -3
- dataface/agent_api/docs/yaml-reference.md +240 -106
- dataface/agent_api/init.py +1 -1
- dataface/agent_api/project_session.py +2 -1
- dataface/agent_api/serve.py +115 -0
- dataface/ai/agent.py +28 -2
- dataface/cli/_project.py +100 -1
- dataface/cli/commands/board_artifact.py +5 -2
- dataface/cli/commands/describe.py +7 -3
- dataface/cli/commands/docs.py +12 -3
- dataface/cli/commands/init.py +7 -14
- dataface/cli/commands/inspect.py +6 -1
- dataface/cli/commands/mcp_init.py +23 -43
- dataface/cli/commands/migrate.py +8 -3
- dataface/cli/commands/query.py +11 -8
- dataface/cli/commands/render.py +20 -9
- dataface/cli/commands/search.py +8 -2
- dataface/cli/commands/serve.py +26 -76
- dataface/cli/commands/skills_init.py +2 -19
- dataface/cli/commands/validate.py +7 -2
- dataface/cli/filesystem_project.py +5 -4
- dataface/cli/main.py +2 -19
- dataface/core/__init__.py +73 -28
- dataface/core/{compile/colors.py → colors.py} +20 -5
- dataface/core/compile/compiler.py +97 -34
- dataface/core/compile/data_table.py +7 -2
- dataface/core/compile/enrich.py +18 -44
- dataface/core/compile/face_warnings.py +46 -8
- dataface/core/compile/format.py +47 -1
- dataface/core/compile/inherit_graph.py +3 -3
- dataface/core/compile/inherit_registry.yaml +1 -1
- dataface/core/compile/migrations.py +112 -9
- dataface/core/compile/models/chart/normalized/kpi.py +0 -4
- dataface/core/compile/models/chart/resolved/_base.py +28 -10
- dataface/core/compile/models/chart/resolved/callout.py +2 -3
- dataface/core/compile/models/chart/resolved/kpi.py +7 -3
- dataface/core/compile/models/chart/resolved/table.py +16 -2
- dataface/core/compile/models/markers.py +1 -1
- dataface/core/compile/models/primitives.py +175 -2
- dataface/core/compile/models/source.py +5 -5
- dataface/core/compile/models/style/authored/__init__.py +32 -8
- dataface/core/compile/models/style/authored/_base.py +126 -69
- dataface/core/compile/models/style/authored/table.py +61 -3
- dataface/core/compile/models/style/resolved/__init__.py +12 -2
- dataface/core/compile/models/style/resolved/_base.py +142 -57
- dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
- dataface/core/compile/models/style/resolved/_cascade.py +141 -54
- dataface/core/compile/models/style/resolved/area.py +1 -1
- dataface/core/compile/models/style/resolved/bar.py +1 -1
- dataface/core/compile/models/style/resolved/line.py +1 -1
- dataface/core/compile/models/style/resolved/scatter.py +3 -2
- dataface/core/compile/models/style/theme/__init__.py +36 -14
- dataface/core/compile/models/style/theme/_chart_base.py +17 -9
- dataface/core/compile/models/style/theme/axis.py +432 -312
- dataface/core/compile/models/style/theme/charts.py +10 -6
- dataface/core/compile/models/style/theme/kpi.py +2 -1
- dataface/core/compile/models/style/theme/page.py +5 -2
- dataface/core/compile/models/style/theme/table.py +2 -2
- dataface/core/compile/models/vega_lite/config.py +1 -1
- dataface/core/compile/normalize_charts.py +6 -2
- dataface/core/compile/normalize_queries.py +21 -7
- dataface/core/compile/resolve/__init__.py +2 -0
- dataface/core/compile/resolve/_axes.py +147 -44
- dataface/core/compile/resolve/_channels.py +1 -1
- dataface/core/compile/resolve/_dispatch.py +1 -1
- dataface/core/compile/resolve/_domain.py +49 -32
- dataface/core/compile/resolve/_kwargs.py +39 -12
- dataface/core/compile/resolve/_marks.py +8 -3
- dataface/core/compile/resolve/_palette.py +7 -3
- dataface/core/compile/resolve/_table.py +124 -33
- dataface/core/compile/resolve/area.py +48 -18
- dataface/core/compile/resolve/bar.py +245 -64
- dataface/core/compile/resolve/geo.py +10 -3
- dataface/core/compile/resolve/heatmap.py +16 -8
- dataface/core/compile/resolve/line.py +35 -12
- dataface/core/compile/resolve/pie.py +4 -1
- dataface/core/compile/resolve/pie_attachment.py +1 -1
- dataface/core/compile/resolve/scatter.py +25 -13
- dataface/core/compile/resolve/simple.py +25 -2
- dataface/core/compile/schema_renderers/prompt.py +1 -1
- dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
- dataface/core/compile/source_map.py +211 -28
- dataface/core/compile/style_cascade.py +126 -55
- dataface/core/compile/yaml_error_formatter.py +1 -1
- dataface/core/connections.py +10 -3
- dataface/core/dashboard.py +24 -18
- dataface/core/defaults/themes/_base.yaml +49 -39
- dataface/core/defaults/themes/cream.yaml +4 -4
- dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
- dataface/core/defaults/themes/editorial.yaml +6 -6
- dataface/core/defaults/themes/neon.yaml +15 -8
- dataface/core/defaults/themes/plain.yaml +6 -4
- dataface/core/defaults/themes/stark.yaml +16 -7
- dataface/core/defaults/themes/vivid.yaml +15 -8
- dataface/core/diagnostics/__init__.py +2 -2
- dataface/core/diagnostics/codes_compile.py +13 -31
- dataface/core/diagnostics/codes_execute.py +79 -0
- dataface/core/diagnostics/codes_render.py +87 -28
- dataface/core/diagnostics/diagnostic.py +30 -0
- dataface/core/diagnostics/hints.py +19 -0
- dataface/core/dialects/base.py +17 -0
- dataface/core/dialects/postgres.py +14 -0
- dataface/core/dialects/redshift.py +5 -0
- dataface/core/execute/__init__.py +59 -14
- dataface/core/execute/adapters/dbt_adapter.py +4 -1
- dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
- dataface/core/execute/adapters/dbt_utils.py +84 -20
- dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
- dataface/core/execute/adapters/sql_adapter.py +72 -4
- dataface/core/execute/dbt_jinja.py +65 -10
- dataface/core/execute/executor.py +69 -16
- dataface/core/font_measure.py +28 -0
- dataface/core/fonts.py +8 -5
- dataface/core/inspect/__init__.py +46 -6
- dataface/core/project.py +13 -1
- dataface/core/render/chart/data_table_attachment.py +14 -12
- dataface/core/render/chart/emitters/_cartesian.py +21 -18
- dataface/core/render/chart/emitters/_channels.py +56 -8
- dataface/core/render/chart/emitters/_label_overlap.py +18 -15
- dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
- dataface/core/render/chart/emitters/area.py +3 -3
- dataface/core/render/chart/emitters/bar.py +60 -35
- dataface/core/render/chart/emitters/geo.py +8 -13
- dataface/core/render/chart/emitters/heatmap.py +20 -26
- dataface/core/render/chart/emitters/line.py +2 -2
- dataface/core/render/chart/emitters/scatter.py +7 -30
- dataface/core/render/chart/features/baseline.py +22 -9
- dataface/core/render/chart/features/endpoint_labels.py +55 -32
- dataface/core/render/chart/features/mirror_axis.py +12 -9
- dataface/core/render/chart/features/value_labels.py +1 -1
- dataface/core/render/chart/kpi.py +11 -90
- dataface/core/render/chart/presentation.py +6 -8
- dataface/core/render/chart/rendering.py +3 -12
- dataface/core/render/chart/session.py +10 -0
- dataface/core/render/chart/spec.py +3 -0
- dataface/core/render/chart/spec_builders.py +18 -18
- dataface/core/render/chart/table.py +81 -36
- dataface/core/render/chart/table_support.py +2 -53
- dataface/core/render/chart/time_unit_detect.py +3 -2
- dataface/core/render/chart/translate.py +82 -44
- dataface/core/render/chart/type_inference.py +45 -25
- dataface/core/render/chart/validation.py +91 -0
- dataface/core/render/chart/vega_lite.py +30 -11
- dataface/core/render/chart/vl_field_maps.py +111 -76
- dataface/core/render/converters/chart.py +16 -6
- dataface/core/render/faces.py +3 -2
- dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
- dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
- dataface/core/render/fonts/_font_face.css +10 -0
- dataface/core/render/layout_sizing.py +267 -238
- dataface/core/render/numeral_expr.py +80 -0
- dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
- dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
- dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
- dataface/core/render/warnings/palette_unsupported.py +6 -0
- dataface/core/render/warnings/pie_dominant_segment.py +1 -0
- dataface/core/render/warnings/pie_too_many_segments.py +1 -0
- dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
- dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
- dataface/core/render/warnings/redundant_encoding.py +20 -1
- dataface/core/render/warnings/registry.py +7 -3
- dataface/core/render/warnings/temporal_single_point.py +1 -0
- dataface/core/render/warnings/too_many_color_categories.py +1 -0
- dataface/core/render/warnings/too_many_x_categories.py +18 -2
- dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
- dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
- dataface/core/render_format.py +18 -0
- dataface/core/text/format_d3.py +56 -0
- dataface/core/text/numeral_scale.py +218 -0
- dataface/core/utils.py +103 -10
- dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
- dataface/data/schemas/yaml/0.3.2.json +1 -0
- dataface/data/schemas/yaml/manifest.json +7 -0
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/licenses/LICENSE +0 -0
|
@@ -18,6 +18,7 @@ from dataface.core.render.chart.emitters._cartesian import (
|
|
|
18
18
|
last_nonnull_value_per_series,
|
|
19
19
|
sorted_series_by_stack_order,
|
|
20
20
|
)
|
|
21
|
+
from dataface.core.utils import numeric_column_values, stacked_x_domain_order
|
|
21
22
|
|
|
22
23
|
# Cascade geometry constants.
|
|
23
24
|
# 1.3: bounding rects kiss at 1.0× but descenders on ascenders touch; 1.3
|
|
@@ -124,7 +125,8 @@ def _y_domain(
|
|
|
124
125
|
back to raw data range when no explicit domain is authored.
|
|
125
126
|
"""
|
|
126
127
|
if axis_y.scale is not None:
|
|
127
|
-
|
|
128
|
+
_ay_cont_el = axis_y.scale.continuous
|
|
129
|
+
domain = _ay_cont_el.domain if _ay_cont_el is not None else None
|
|
128
130
|
if isinstance(domain, list) and len(domain) == 2:
|
|
129
131
|
try:
|
|
130
132
|
lo, hi = float(domain[0]), float(domain[1])
|
|
@@ -191,8 +193,8 @@ def _wide_y_domain(
|
|
|
191
193
|
axis_y: ResolvedAxisStyle,
|
|
192
194
|
) -> tuple[float, float]:
|
|
193
195
|
"""Return the shared measure extent for an area with folded wide measures."""
|
|
194
|
-
if axis_y.scale is not None:
|
|
195
|
-
domain = axis_y.scale.domain
|
|
196
|
+
if axis_y.scale is not None and axis_y.scale.continuous is not None:
|
|
197
|
+
domain = axis_y.scale.continuous.domain
|
|
196
198
|
if isinstance(domain, list) and len(domain) == 2:
|
|
197
199
|
try:
|
|
198
200
|
lo, hi = float(domain[0]), float(domain[1])
|
|
@@ -263,6 +265,30 @@ def _cascade_gap(
|
|
|
263
265
|
return label_gap_px * (y_range / (height * _PLOT_AREA_FRACTION))
|
|
264
266
|
|
|
265
267
|
|
|
268
|
+
def _refuse_unorderable_sort(
|
|
269
|
+
chart_id: str, data: list[dict[str, Any]], sort: Any
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Refuse a `sort:` whose column carries no numbers to total.
|
|
272
|
+
|
|
273
|
+
``stacked_x_domain_order`` reproduces Vega-Lite's domain order by summing
|
|
274
|
+
the sort field per category, which is what VL's default ``sum`` op does.
|
|
275
|
+
On a non-numeric column VL concatenates the strings instead — an order this
|
|
276
|
+
cannot reproduce — so the rail would anchor on a row VL does not draw on
|
|
277
|
+
top. Resolve steers the default away from this shape; an explicit opt-in
|
|
278
|
+
lands here and gets told why.
|
|
279
|
+
"""
|
|
280
|
+
if sort is None or numeric_column_values(data, sort.by):
|
|
281
|
+
return
|
|
282
|
+
raise ChartDataError(
|
|
283
|
+
f"chart.sort by {sort.by!r} cannot be combined with stacked bar "
|
|
284
|
+
"endpoint labels — the label rail reproduces Vega-Lite's domain order "
|
|
285
|
+
"by totalling that column per category, and it carries no numeric "
|
|
286
|
+
"values. To fix: sort by a measure, or set "
|
|
287
|
+
"endpoint_labels.visible: false on this chart.",
|
|
288
|
+
chart_id,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
266
292
|
def _stacked_midpoints(
|
|
267
293
|
data: list[dict[str, Any]],
|
|
268
294
|
x_field: str,
|
|
@@ -270,6 +296,8 @@ def _stacked_midpoints(
|
|
|
270
296
|
series_field: str,
|
|
271
297
|
stack_mode: str,
|
|
272
298
|
max_column_total: float,
|
|
299
|
+
sort_by: str,
|
|
300
|
+
descending: bool,
|
|
273
301
|
stack_order: str | None = None,
|
|
274
302
|
) -> list[tuple[str, float]]:
|
|
275
303
|
"""Compute cumulative segment midpoints for vertical stacked bars or areas.
|
|
@@ -299,14 +327,8 @@ def _stacked_midpoints(
|
|
|
299
327
|
``_stacked_y_domain`` computes for this data when ``stack_mode ==
|
|
300
328
|
"center"``.
|
|
301
329
|
"""
|
|
302
|
-
|
|
303
|
-
last_x = None
|
|
304
|
-
for row in data:
|
|
305
|
-
v = row.get(x_field)
|
|
306
|
-
if v is None:
|
|
307
|
-
continue
|
|
308
|
-
if last_x is None or v > last_x:
|
|
309
|
-
last_x = v
|
|
330
|
+
domain = stacked_x_domain_order(data, x_field, sort_by, descending)
|
|
331
|
+
last_x = domain[-1] if domain else None
|
|
310
332
|
|
|
311
333
|
values_at_last: dict[str, float] = {}
|
|
312
334
|
for row in data:
|
|
@@ -405,11 +427,14 @@ def _cumulative_midpoints(
|
|
|
405
427
|
x_field: str,
|
|
406
428
|
y_field: str,
|
|
407
429
|
series_field: str,
|
|
430
|
+
sort_by: str,
|
|
431
|
+
descending: bool,
|
|
408
432
|
stack_mode: str = "zero",
|
|
409
433
|
) -> list[tuple[str, float]]:
|
|
410
434
|
"""Return (series, x_midpoint) for the top categorical row (un-nudged).
|
|
411
435
|
|
|
412
|
-
The "top row" is the
|
|
436
|
+
The "top row" is the first value of the rendered x domain — alphabetically
|
|
437
|
+
first, or first under an authored ``sort:``. Midpoint is the
|
|
413
438
|
cumulative x at the series segment's centre, ordered largest-global-sum first
|
|
414
439
|
(matching V1's _apply_stacked_bar_z_order default/"value" ordering, which VL
|
|
415
440
|
enforces via encoding.order + joinaggregate). Sufficient for correct
|
|
@@ -418,8 +443,7 @@ def _cumulative_midpoints(
|
|
|
418
443
|
For ``stack_mode == "normalize"``, midpoints are divided by the top-row total
|
|
419
444
|
so they land on the 0..1 scale that VL renders for normalize stacks.
|
|
420
445
|
"""
|
|
421
|
-
|
|
422
|
-
x_values = sorted({row[x_field] for row in data if row.get(x_field) is not None})
|
|
446
|
+
x_values = stacked_x_domain_order(data, x_field, sort_by, descending)
|
|
423
447
|
if not x_values:
|
|
424
448
|
return []
|
|
425
449
|
top_row_x = x_values[0]
|
|
@@ -517,10 +541,7 @@ class EndpointLabelFeature:
|
|
|
517
541
|
# Top-row series rail only applies to stacked horizontals; grouped
|
|
518
542
|
# bars sit side-by-side and form no stacks to label.
|
|
519
543
|
return chart.stack not in (None, "none")
|
|
520
|
-
|
|
521
|
-
# area's streamgraph case below), so it still skips the feature
|
|
522
|
-
# rather than mislabeling a diverging domain.
|
|
523
|
-
return not (isinstance(chart, ResolvedBarChart) and chart.stack == "center")
|
|
544
|
+
return True
|
|
524
545
|
|
|
525
546
|
def apply(
|
|
526
547
|
self,
|
|
@@ -579,6 +600,7 @@ class EndpointLabelFeature:
|
|
|
579
600
|
y_field = chart.y
|
|
580
601
|
if not isinstance(y_field, str):
|
|
581
602
|
return spec
|
|
603
|
+
_refuse_unorderable_sort(chart.id, data, chart.sort)
|
|
582
604
|
if chart.stack == "center":
|
|
583
605
|
raise ChartDataError(
|
|
584
606
|
"stack: 'center' is not supported for horizontal stacked bar "
|
|
@@ -586,13 +608,6 @@ class EndpointLabelFeature:
|
|
|
586
608
|
"axis, not the diverging (-Σ/2..+Σ/2) center-stack domain.",
|
|
587
609
|
chart.id,
|
|
588
610
|
)
|
|
589
|
-
if chart.sort is not None:
|
|
590
|
-
raise ChartDataError(
|
|
591
|
-
"chart.sort cannot be combined with horizontal stacked bar "
|
|
592
|
-
"endpoint labels — the label rail anchors at the "
|
|
593
|
-
"alphabetical-first row, which chart.sort would move.",
|
|
594
|
-
chart.id,
|
|
595
|
-
)
|
|
596
611
|
if _has_negative_measure(data, y_field):
|
|
597
612
|
raise ChartDataError(
|
|
598
613
|
"negative values are not supported for horizontal stacked bar "
|
|
@@ -600,7 +615,13 @@ class EndpointLabelFeature:
|
|
|
600
615
|
chart.id,
|
|
601
616
|
)
|
|
602
617
|
positions = _cumulative_midpoints(
|
|
603
|
-
data,
|
|
618
|
+
data,
|
|
619
|
+
x_field,
|
|
620
|
+
y_field,
|
|
621
|
+
series_field,
|
|
622
|
+
chart.sort.by if chart.sort else "",
|
|
623
|
+
bool(chart.sort and chart.sort.order == "desc"),
|
|
624
|
+
stack_mode=chart.stack or "zero",
|
|
604
625
|
)
|
|
605
626
|
# For normalize stacks the chart pane's x encoding must explicitly
|
|
606
627
|
# pin [0, 1] so the shared vconcat x-scale propagates to the rail
|
|
@@ -645,12 +666,10 @@ class EndpointLabelFeature:
|
|
|
645
666
|
) and chart.stack not in (None, "none")
|
|
646
667
|
if is_stacked:
|
|
647
668
|
assert isinstance(chart, (ResolvedBarChart, ResolvedAreaChart))
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
assert not (
|
|
653
|
-
isinstance(chart, ResolvedBarChart) and chart.stack == "center"
|
|
669
|
+
_refuse_unorderable_sort(
|
|
670
|
+
chart.id,
|
|
671
|
+
data,
|
|
672
|
+
chart.sort if isinstance(chart, ResolvedBarChart) else None,
|
|
654
673
|
)
|
|
655
674
|
if _has_negative_measure(data, y_field):
|
|
656
675
|
raise ChartDataError(
|
|
@@ -669,6 +688,8 @@ class EndpointLabelFeature:
|
|
|
669
688
|
if isinstance(chart, ResolvedBarChart)
|
|
670
689
|
else NATIVE_STACK_ORDER
|
|
671
690
|
)
|
|
691
|
+
# Area has no authored sort; only bar carries one.
|
|
692
|
+
_sort = chart.sort if isinstance(chart, ResolvedBarChart) else None
|
|
672
693
|
y_domain_min, y_domain_max = _stacked_y_domain(
|
|
673
694
|
data, x_field, y_field, stack_mode
|
|
674
695
|
)
|
|
@@ -679,6 +700,8 @@ class EndpointLabelFeature:
|
|
|
679
700
|
series_field,
|
|
680
701
|
stack_mode,
|
|
681
702
|
y_domain_max,
|
|
703
|
+
_sort.by if _sort else "",
|
|
704
|
+
bool(_sort and _sort.order == "desc"),
|
|
682
705
|
stack_order=stack_order,
|
|
683
706
|
)
|
|
684
707
|
# Pin the main pane's y domain so VL's shared hconcat y-scale
|
|
@@ -108,7 +108,7 @@ class MirrorAxisFeature:
|
|
|
108
108
|
if isinstance(mirror, AxisMirrorStyle):
|
|
109
109
|
if mirror.format is not None:
|
|
110
110
|
ghost_axis["format"] = mirror.format
|
|
111
|
-
# The primary may carry a labelExpr (style.axis_y.
|
|
111
|
+
# The primary may carry a labelExpr (style.axis_y.labels.expr),
|
|
112
112
|
# which VL prefers over format — drop the inherited copy so the
|
|
113
113
|
# explicit per-edge format actually takes effect.
|
|
114
114
|
ghost_axis.pop("labelExpr", None)
|
|
@@ -140,7 +140,10 @@ class MirrorAxisFeature:
|
|
|
140
140
|
tuple(axis_y.tick_values), ghost_format
|
|
141
141
|
)
|
|
142
142
|
elif isinstance(chart.y, str):
|
|
143
|
-
|
|
143
|
+
_ay_cont_m = (
|
|
144
|
+
axis_y.scale.continuous if axis_y.scale is not None else None
|
|
145
|
+
)
|
|
146
|
+
domain = _ay_cont_m.domain if _ay_cont_m is not None else None
|
|
144
147
|
bounds = numeric_domain_bounds(domain)
|
|
145
148
|
values = numeric_values(chart_rows(chart, datasets), (chart.y,)) + (
|
|
146
149
|
list(bounds) if bounds is not None else []
|
|
@@ -148,14 +151,14 @@ class MirrorAxisFeature:
|
|
|
148
151
|
labels = estimated_quantitative_tick_labels(values, ghost_format)
|
|
149
152
|
if labels:
|
|
150
153
|
tick_size = (
|
|
151
|
-
axis_y.ticks.
|
|
152
|
-
if axis_y.ticks.
|
|
154
|
+
axis_y.ticks.length
|
|
155
|
+
if axis_y.ticks.length is not None
|
|
153
156
|
else DEFAULT_VL_TICK_SIZE
|
|
154
157
|
)
|
|
155
|
-
padding = measured_label_padding(labels, axis_y.
|
|
158
|
+
padding = measured_label_padding(labels, axis_y.labels.font, tick_size)
|
|
156
159
|
label_limit = (
|
|
157
|
-
axis_y.
|
|
158
|
-
if axis_y.
|
|
160
|
+
axis_y.labels.max_width
|
|
161
|
+
if axis_y.labels.max_width is not None
|
|
159
162
|
else DEFAULT_VL_LABEL_LIMIT
|
|
160
163
|
)
|
|
161
164
|
ghost_axis["labelPadding"] = cap_padding_to_label_limit(
|
|
@@ -163,12 +166,12 @@ class MirrorAxisFeature:
|
|
|
163
166
|
)
|
|
164
167
|
else:
|
|
165
168
|
raise ChartDataError(
|
|
166
|
-
f"axis_y.
|
|
169
|
+
f"axis_y.labels.align: {ghost_align!r} cannot be "
|
|
167
170
|
"combined with axis_y.mirror — the alignment that keeps "
|
|
168
171
|
f"labels outside the plot on the {primary} edge draws the "
|
|
169
172
|
f"mirrored {opposite}-edge labels back across the axis "
|
|
170
173
|
"into the plot, and Dataface has no baked tick content to "
|
|
171
|
-
"measure a safe labelPadding from. Omit
|
|
174
|
+
"measure a safe labelPadding from. Omit labels.align; each "
|
|
172
175
|
"edge then uses Vega-Lite's safe per-orient default.",
|
|
173
176
|
chart_id=chart.id,
|
|
174
177
|
)
|
|
@@ -18,7 +18,7 @@ import html
|
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
|
-
from dataface.core.
|
|
21
|
+
from dataface.core.colors import sanitize_color
|
|
22
22
|
from dataface.core.compile.config import get_chart_rendering
|
|
23
23
|
from dataface.core.compile.format import resolve_format
|
|
24
24
|
from dataface.core.compile.models.chart.authored import (
|
|
@@ -28,11 +28,11 @@ from dataface.core.compile.models.chart.authored import (
|
|
|
28
28
|
match_predicate,
|
|
29
29
|
)
|
|
30
30
|
from dataface.core.compile.models.chart.resolved import (
|
|
31
|
+
FormatState,
|
|
31
32
|
ResolvedKpiChart,
|
|
32
33
|
ResolvedStyleChannel,
|
|
33
34
|
)
|
|
34
35
|
from dataface.core.compile.models.primitives import (
|
|
35
|
-
FormatConfig,
|
|
36
36
|
SpacingValues,
|
|
37
37
|
ToneLiteral,
|
|
38
38
|
)
|
|
@@ -62,6 +62,7 @@ from dataface.core.render.chart.title_overflow import (
|
|
|
62
62
|
)
|
|
63
63
|
from dataface.core.render.format_utils import format_kpi_parts
|
|
64
64
|
from dataface.core.render.utils import resolve_tone_color
|
|
65
|
+
from dataface.core.utils import coerce_numeric_cell
|
|
65
66
|
|
|
66
67
|
# Typographic ratios for KPI geometry. Tuned by eye against the playground
|
|
67
68
|
# specimen.
|
|
@@ -214,87 +215,9 @@ def _resolve_value(raw: str, row: dict[str, Any], chart_id: str) -> tuple[Any, s
|
|
|
214
215
|
)
|
|
215
216
|
|
|
216
217
|
|
|
217
|
-
def _coerce_numeric_value(cell: Any) -> float | None:
|
|
218
|
-
"""Coerce ``cell`` to a float or return None if it should be rendered as a string.
|
|
219
|
-
|
|
220
|
-
``bool`` is treated as a string-class value even though it is technically
|
|
221
|
-
an ``int`` subclass — rendering ``True`` through the narrative-notation
|
|
222
|
-
pipeline as ``1.0`` would be magic behavior the author did not ask for.
|
|
223
|
-
"""
|
|
224
|
-
if cell is None or isinstance(cell, bool):
|
|
225
|
-
return None
|
|
226
|
-
if isinstance(cell, (int, float)):
|
|
227
|
-
return float(cell)
|
|
228
|
-
try:
|
|
229
|
-
return float(cell)
|
|
230
|
-
except (ValueError, TypeError):
|
|
231
|
-
# String literals like "At risk" stay as strings.
|
|
232
|
-
return None
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
# d3's first SI prefix ("k") engages at 1000. Below it, ".2s" doesn't compact —
|
|
236
|
-
# it rounds to 2 significant figures on plain digits instead (131 -> "130"),
|
|
237
|
-
# which drops information the author never asked to lose. Digit-integrity
|
|
238
|
-
# rule: rounding is acceptable under SI compaction, never on plain digits —
|
|
239
|
-
# so the default only compacts once compaction is real.
|
|
240
|
-
_SI_COMPACT_THRESHOLD = 1000.0
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _narrative_format_default(
|
|
244
|
-
format_input: str | FormatConfig | dict[str, Any] | None,
|
|
245
|
-
value: float | None,
|
|
246
|
-
) -> str | FormatConfig | dict[str, Any] | None:
|
|
247
|
-
"""Apply KPI's narrative defaults when the author did not pick them.
|
|
248
|
-
|
|
249
|
-
KPI is a hero number that the reader pauses on, so it defaults to the
|
|
250
|
-
narrative notation register (``1.5mn``) instead of the analytic register
|
|
251
|
-
(``1.5 M``) used on axis ticks and table cells. When neither spec nor
|
|
252
|
-
notation is authored, and ``value`` is large enough to actually compact
|
|
253
|
-
(``|value| >= 1000``), we also default to compact SI form (``".2s"``) so
|
|
254
|
-
narrative notation shows up — notation alone is a no-op against a non-SI
|
|
255
|
-
spec. Below that threshold there is nothing to compact, so the value
|
|
256
|
-
renders its exact digits (no spec at all — the render layer's raw
|
|
257
|
-
numeric fallback) rather than a misleadingly-rounded plain number.
|
|
258
|
-
"""
|
|
259
|
-
compact_eligible = value is not None and abs(value) >= _SI_COMPACT_THRESHOLD
|
|
260
|
-
if format_input is None:
|
|
261
|
-
if compact_eligible:
|
|
262
|
-
return FormatConfig(spec=".2s", notation="narrative")
|
|
263
|
-
return None
|
|
264
|
-
if isinstance(format_input, FormatConfig):
|
|
265
|
-
spec = format_input.spec
|
|
266
|
-
notation = format_input.notation
|
|
267
|
-
updates: dict[str, Any] = {}
|
|
268
|
-
if notation is None:
|
|
269
|
-
updates["notation"] = "narrative"
|
|
270
|
-
if (
|
|
271
|
-
spec is None
|
|
272
|
-
and (notation is None or notation in ("narrative", "analytic"))
|
|
273
|
-
and compact_eligible
|
|
274
|
-
):
|
|
275
|
-
updates["spec"] = ".2s"
|
|
276
|
-
return format_input.model_copy(update=updates) if updates else format_input
|
|
277
|
-
if isinstance(format_input, dict):
|
|
278
|
-
spec = format_input.get("spec")
|
|
279
|
-
notation = format_input.get("notation")
|
|
280
|
-
updated = dict(format_input)
|
|
281
|
-
if notation is None:
|
|
282
|
-
updated["notation"] = "narrative"
|
|
283
|
-
if (
|
|
284
|
-
spec is None
|
|
285
|
-
and (notation is None or notation in ("narrative", "analytic"))
|
|
286
|
-
and compact_eligible
|
|
287
|
-
):
|
|
288
|
-
updated["spec"] = ".2s"
|
|
289
|
-
return updated
|
|
290
|
-
# Plain string spec (e.g. ",.0f"): respect the author's spec; just add
|
|
291
|
-
# narrative notation so any SI-form spec they used renders narratively.
|
|
292
|
-
return FormatConfig(spec=str(format_input), notation="narrative")
|
|
293
|
-
|
|
294
|
-
|
|
295
218
|
def _format_value_parts(
|
|
296
219
|
cell: Any,
|
|
297
|
-
format_input:
|
|
220
|
+
format_input: FormatState,
|
|
298
221
|
formats: dict[str, Any] | None = None,
|
|
299
222
|
) -> tuple[str, str, str, bool]:
|
|
300
223
|
"""Format a KPI cell into ``(prefix, number_str, suffix, is_numeric)``.
|
|
@@ -302,7 +225,7 @@ def _format_value_parts(
|
|
|
302
225
|
Non-numeric cells are returned as a string in ``number_str`` with empty
|
|
303
226
|
affixes — used for status-style KPIs like ``"At risk"``.
|
|
304
227
|
"""
|
|
305
|
-
numeric =
|
|
228
|
+
numeric = coerce_numeric_cell(cell)
|
|
306
229
|
if numeric is None:
|
|
307
230
|
text = "" if cell is None else str(cell)
|
|
308
231
|
return "", text, "", False
|
|
@@ -695,7 +618,7 @@ def _resolve_kpi_layout(
|
|
|
695
618
|
|
|
696
619
|
def _resolve_kpi_colors(
|
|
697
620
|
row: dict[str, Any],
|
|
698
|
-
main_format:
|
|
621
|
+
main_format: FormatState,
|
|
699
622
|
kpi_config: KpiChartStyle,
|
|
700
623
|
resolved_channels: dict[str, ResolvedStyleChannel],
|
|
701
624
|
chart_background: str | None,
|
|
@@ -866,13 +789,11 @@ def _render_kpi_svg_core(
|
|
|
866
789
|
raw_value = chart.value
|
|
867
790
|
row = data[0]
|
|
868
791
|
cell, _value_column = _resolve_value(raw_value, row, chart_id)
|
|
869
|
-
#
|
|
870
|
-
#
|
|
871
|
-
#
|
|
872
|
-
#
|
|
873
|
-
main_format =
|
|
874
|
-
kpi_config.value.format, _coerce_numeric_value(cell)
|
|
875
|
-
)
|
|
792
|
+
# chart.format is the final headline format — resolve() already applied
|
|
793
|
+
# the narrative-notation and SI-compaction defaults against this row's
|
|
794
|
+
# value; render just consumes it (support-block format stays analytic,
|
|
795
|
+
# resolved separately in _resolve_support_row).
|
|
796
|
+
main_format = chart.format
|
|
876
797
|
prefix, number_str, suffix, value_is_numeric = _format_value_parts(
|
|
877
798
|
cell, main_format, formats
|
|
878
799
|
)
|
|
@@ -10,8 +10,6 @@ from __future__ import annotations
|
|
|
10
10
|
import copy
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
-
from dataface.core.compile.models.style.resolved import ResolvedChartsStyle
|
|
14
|
-
|
|
15
13
|
# Internal field name for the calculate-transform output backing the href
|
|
16
14
|
# click-interactivity encoding. Consumed by translate.py.
|
|
17
15
|
_HREF_FIELD = "__df_href__"
|
|
@@ -32,7 +30,7 @@ def overlay_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, An
|
|
|
32
30
|
|
|
33
31
|
def apply_presentation_defaults(
|
|
34
32
|
spec: dict[str, Any],
|
|
35
|
-
|
|
33
|
+
background: str,
|
|
36
34
|
effective_vega_config: dict[str, Any],
|
|
37
35
|
) -> dict[str, Any]:
|
|
38
36
|
"""Apply compiled presentation defaults to an emitted Vega-Lite spec once.
|
|
@@ -53,15 +51,15 @@ def apply_presentation_defaults(
|
|
|
53
51
|
|
|
54
52
|
result_spec["config"] = compiled
|
|
55
53
|
|
|
56
|
-
# Background cascade:
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# spec root (projection present, no authored background).
|
|
54
|
+
# Background cascade: background is the chart's own effective background
|
|
55
|
+
# (chart-local style.<family>.background when authored, otherwise the
|
|
56
|
+
# board/face background), baked at resolve time. Skip for geo charts that
|
|
57
|
+
# keep a transparent spec root (projection present, no authored background).
|
|
60
58
|
preserve_transparent_background = (
|
|
61
59
|
result_spec.get("background") is None and "projection" in result_spec
|
|
62
60
|
)
|
|
63
61
|
if not preserve_transparent_background:
|
|
64
|
-
result_spec["background"] =
|
|
62
|
+
result_spec["background"] = background
|
|
65
63
|
|
|
66
64
|
result_spec["config"] = overlay_merge(result_spec["config"], authored_config)
|
|
67
65
|
return result_spec
|
|
@@ -159,19 +159,10 @@ def render_layout_item(
|
|
|
159
159
|
rendered = ""
|
|
160
160
|
actual_height = height
|
|
161
161
|
if item.type == "chart" and item.chart:
|
|
162
|
-
# Per-family padding: baked into layout_padding at
|
|
163
|
-
#
|
|
164
|
-
# full pipeline (e.g. unit tests).
|
|
165
|
-
from dataface.core.compile.style_cascade import effective_chart_padding
|
|
166
|
-
|
|
162
|
+
# Per-family padding: baked into layout_padding at construction time
|
|
163
|
+
# by every family resolver — the single source of truth.
|
|
167
164
|
card_padding = float(resolved_style.board.card_padding)
|
|
168
|
-
chart_padding =
|
|
169
|
-
item.chart.layout_padding
|
|
170
|
-
if item.chart.layout_padding is not None
|
|
171
|
-
else effective_chart_padding(
|
|
172
|
-
resolved_style.charts, item.chart.chart_type
|
|
173
|
-
)
|
|
174
|
-
)
|
|
165
|
+
chart_padding = item.chart.layout_padding
|
|
175
166
|
chart_svg, chart_actual_height = render_chart_item(
|
|
176
167
|
item.chart,
|
|
177
168
|
executor,
|
|
@@ -23,6 +23,9 @@ from dataface.core.compile.models.chart.resolved import (
|
|
|
23
23
|
ResolvedSparkBarChart,
|
|
24
24
|
ResolvedTableChart,
|
|
25
25
|
)
|
|
26
|
+
from dataface.core.compile.models.chart.resolved._base import (
|
|
27
|
+
_SharedResolvedChartFields,
|
|
28
|
+
)
|
|
26
29
|
from dataface.core.compile.models.chart.resolved._layer import LayeredResolvedChart
|
|
27
30
|
from dataface.core.compile.models.style.resolved import ResolvedStyle
|
|
28
31
|
from dataface.core.render.chart._types import VLDict
|
|
@@ -134,6 +137,13 @@ class FaceRenderSession:
|
|
|
134
137
|
spec.title = chart.title
|
|
135
138
|
spec.subtitle = chart.subtitle
|
|
136
139
|
spec.title_font = getattr(chart.style, "title_font", None)
|
|
140
|
+
# get_emitter above only returns for the VL families, all of which are
|
|
141
|
+
# _SharedResolvedChartFields subclasses (KPI/callout/table/spark_bar
|
|
142
|
+
# are dispatched to render_svg_family before this point) — background
|
|
143
|
+
# and title_style are guaranteed present, so read them directly.
|
|
144
|
+
assert isinstance(chart, _SharedResolvedChartFields)
|
|
145
|
+
spec.background = chart.background
|
|
146
|
+
spec.title_style = chart.title_style
|
|
137
147
|
return spec
|
|
138
148
|
|
|
139
149
|
def finalize_vl(self, spec: ChartSpec) -> VLDict:
|
|
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
20
20
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
22
22
|
from dataface.core.compile.models.primitives import ResolvedFontStyle
|
|
23
|
+
from dataface.core.compile.models.style.theme import TitleStyle
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
@dataclass(frozen=True)
|
|
@@ -145,6 +146,8 @@ class ChartSpec:
|
|
|
145
146
|
title: str | None = None
|
|
146
147
|
subtitle: str | None = None
|
|
147
148
|
title_font: ResolvedFontStyle | None = None
|
|
149
|
+
background: str | None = None
|
|
150
|
+
title_style: TitleStyle | None = None
|
|
148
151
|
geo_data: dict[str, Any] | None = None
|
|
149
152
|
transforms: list[dict[str, Any]] = field(default_factory=list)
|
|
150
153
|
# Row data for non-geo VL families; set centrally by FaceRenderSession.emit_chart.
|
|
@@ -7,22 +7,21 @@ from typing import TYPE_CHECKING, Any
|
|
|
7
7
|
from dataface.core.text.case import apply_case
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
|
-
from dataface.core.compile.models.style.resolved import (
|
|
11
|
-
ResolvedChartsStyle,
|
|
12
|
-
)
|
|
13
10
|
from dataface.core.compile.models.style.theme import PaddingStyle
|
|
11
|
+
from dataface.core.text.case import CaseValue
|
|
14
12
|
|
|
15
13
|
|
|
16
14
|
def additive_padding(card_pad: float, chart_padding: PaddingStyle) -> dict[str, float]:
|
|
17
15
|
"""Add card_padding to per-family chart padding on each side independently.
|
|
18
16
|
|
|
19
|
-
``chart_padding`` is the per-family resolved padding
|
|
20
|
-
``
|
|
21
|
-
InheritSlot and any chart-local
|
|
22
|
-
in by ``build_resolved_style``.
|
|
23
|
-
so author-specified insets compose
|
|
24
|
-
than replacing it. With no chart-local
|
|
25
|
-
{0,0,0,0}, this collapses to a uniform
|
|
17
|
+
``chart_padding`` is the per-family resolved padding — typically
|
|
18
|
+
``resolved_chart.layout_padding``, baked in at construction time. It
|
|
19
|
+
already carries face → family fill via InheritSlot and any chart-local
|
|
20
|
+
``style.<family>.padding`` override merged in by ``build_resolved_style``.
|
|
21
|
+
Each side stacks ON TOP of card_padding so author-specified insets compose
|
|
22
|
+
with the global card layout rather than replacing it. With no chart-local
|
|
23
|
+
override and face default {0,0,0,0}, this collapses to a uniform
|
|
24
|
+
card_pad on all four sides.
|
|
26
25
|
"""
|
|
27
26
|
return {
|
|
28
27
|
"left": card_pad + chart_padding.left,
|
|
@@ -58,18 +57,19 @@ def set_chart_title(
|
|
|
58
57
|
title: str | None,
|
|
59
58
|
subtitle: str | None = None,
|
|
60
59
|
*,
|
|
61
|
-
|
|
60
|
+
case: CaseValue | None = None,
|
|
62
61
|
) -> None:
|
|
63
62
|
"""Apply a standard title block when a title is present.
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
``case`` is a plain title-case value (``chart.title_style.font.case``),
|
|
65
|
+
not a style bag — title-case is VL-presentation-only: ``chart.title``
|
|
66
|
+
itself stays raw because it also backs non-VL consumers (e.g. the
|
|
67
|
+
``data-chart-title`` wire attribute) that emit the authored text as-is.
|
|
68
|
+
Subtitle is not case-transformed — chart subtitles have always been
|
|
69
|
+
emitted raw.
|
|
68
70
|
"""
|
|
69
|
-
if title and
|
|
70
|
-
|
|
71
|
-
if case is not None and case != "none":
|
|
72
|
-
title = apply_case(title, case)
|
|
71
|
+
if title and case is not None and case != "none":
|
|
72
|
+
title = apply_case(title, case)
|
|
73
73
|
if title or subtitle:
|
|
74
74
|
title_block: dict[str, Any] = {"text": title or ""}
|
|
75
75
|
if subtitle:
|