dataface 0.2.1.dev301__py3-none-any.whl → 0.2.1.dev323__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/ai/llm.py CHANGED
@@ -35,6 +35,44 @@ def _llm_timeout() -> httpx.Timeout:
35
35
  )
36
36
 
37
37
 
38
+ def _parse_data_uri(uri: str) -> tuple[str, str]:
39
+ """Split a base64 ``data:`` URI into its media type and payload.
40
+
41
+ Raises ``LLMClientError`` on anything that isn't a base64 data URI — the
42
+ frontend and view already validate the shape, so a miss here is a bug, not
43
+ user input to tolerate.
44
+ """
45
+ prefix, _, data = uri.partition(",")
46
+ if not (prefix.startswith("data:") and prefix.endswith(";base64") and data):
47
+ raise LLMClientError(f"Attachment is not a base64 data URI: {uri[:32]!r}...")
48
+ media_type = prefix[len("data:") : -len(";base64")]
49
+ return media_type, data
50
+
51
+
52
+ def _anthropic_content_block(block: dict[str, Any]) -> dict[str, Any]:
53
+ """Convert one provider-neutral content block to an Anthropic block."""
54
+ block_type = block.get("type")
55
+ if block_type == "text" and isinstance(block.get("text"), str):
56
+ return {"type": "text", "text": block["text"]}
57
+ if block_type == "image_url":
58
+ image_url = block.get("image_url")
59
+ if isinstance(image_url, dict) and isinstance(image_url.get("url"), str):
60
+ media_type, data = _parse_data_uri(image_url["url"])
61
+ return {
62
+ "type": "image",
63
+ "source": {"type": "base64", "media_type": media_type, "data": data},
64
+ }
65
+ if block_type == "document" and isinstance(block.get("url"), str):
66
+ media_type, data = _parse_data_uri(block["url"])
67
+ return {
68
+ "type": "document",
69
+ "source": {"type": "base64", "media_type": media_type, "data": data},
70
+ }
71
+ raise LLMClientError(
72
+ f"Unsupported Anthropic message content block: {block_type!r}."
73
+ )
74
+
75
+
38
76
  def infer_provider(model: str | None) -> str:
39
77
  if model and model.startswith("anthropic:"):
40
78
  return "anthropic"
@@ -314,6 +352,19 @@ class OpenAIClient:
314
352
  }
315
353
  )
316
354
  continue
355
+ if block_type == "document" and isinstance(
356
+ block.get("url"), str
357
+ ):
358
+ media_type, _ = _parse_data_uri(block["url"])
359
+ subtype = media_type.rpartition("/")[2] or "bin"
360
+ responses_content.append(
361
+ {
362
+ "type": "input_file",
363
+ "filename": f"attachment.{subtype}",
364
+ "file_data": block["url"],
365
+ }
366
+ )
367
+ continue
317
368
  raise LLMClientError(
318
369
  f"Unsupported OpenAI message content block: {block_type!r}."
319
370
  )
@@ -523,9 +574,15 @@ class AnthropicClient:
523
574
 
524
575
  content = message.get("content")
525
576
  if content:
526
- converted.append(
527
- {"role": role, "content": [{"type": "text", "text": content}]}
528
- )
577
+ if isinstance(content, list):
578
+ if role != "user":
579
+ raise LLMClientError(
580
+ "Only user messages may contain multimodal content."
581
+ )
582
+ blocks = [_anthropic_content_block(block) for block in content]
583
+ else:
584
+ blocks = [{"type": "text", "text": content}]
585
+ converted.append({"role": role, "content": blocks})
529
586
 
530
587
  flush_tool_results()
531
588
  return converted
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  from dataclasses import dataclass
7
+ from datetime import date, datetime
7
8
  from typing import TYPE_CHECKING, Any
8
9
 
9
10
  from dataface.core.compile.errors import CompilationError
@@ -28,6 +29,7 @@ from dataface.core.compile.models.variable.authored import (
28
29
  Variable,
29
30
  VariableInputType,
30
31
  )
32
+ from dataface.core.compile.variables import parse_iso_date
31
33
  from dataface.core.errors.codes_compile import (
32
34
  DF_COMPILE_SOURCE_REQUIRED,
33
35
  DF_COMPILE_UNKNOWN_VARIABLE,
@@ -505,11 +507,24 @@ _TYPE_VALIDATORS: dict[str, tuple[type | tuple[type, ...], str]] = {
505
507
  "range": ((int, float), "a number"),
506
508
  "checkbox": (bool, "a boolean"),
507
509
  "multiselect": (list, "a list"),
508
- "date": (str, "a string"),
509
- "datepicker": (str, "a string"),
510
+ # A date default may be an ISO string ("2024-01-01") or a native date object
511
+ # (unquoted YAML `2024-01-01`, which safe_load types as datetime.date).
512
+ "date": ((str, date), "a string or ISO date"),
513
+ "datepicker": ((str, date), "a string or ISO date"),
510
514
  }
511
515
 
512
516
 
517
+ def _validate_date_string(var_name: str, label: str, value: str) -> None:
518
+ """Raise CompilationError if *value* is not a parseable ISO date."""
519
+ try:
520
+ parse_iso_date(value)
521
+ except ValueError:
522
+ raise CompilationError(
523
+ f"Variable '{var_name}': {label} must be an ISO date (YYYY-MM-DD), "
524
+ f"got {value!r}"
525
+ ) from None
526
+
527
+
513
528
  def validate_variable_value(var_name: str, var: Variable, value: Any) -> None:
514
529
  """Validate a variable value against its input type.
515
530
 
@@ -537,11 +552,21 @@ def validate_variable_value(var_name: str, var: Variable, value: Any) -> None:
537
552
  f"got {type(value).__name__}"
538
553
  )
539
554
  for i, v in enumerate(value):
540
- if v is not None and not isinstance(v, str):
555
+ if v is None:
556
+ continue
557
+ if isinstance(v, datetime):
558
+ raise CompilationError(
559
+ f"Variable '{var_name}': daterange[{i}] must be a calendar date, "
560
+ f"not a datetime, got {v!r}"
561
+ )
562
+ if isinstance(v, date):
563
+ continue
564
+ if not isinstance(v, str):
541
565
  raise CompilationError(
542
566
  f"Variable '{var_name}': daterange[{i}] must be string, "
543
567
  f"got {type(v).__name__}"
544
568
  )
569
+ _validate_date_string(var_name, f"daterange[{i}]", v)
545
570
  return
546
571
 
547
572
  # Standard type validation from dict
@@ -564,6 +589,20 @@ def validate_variable_value(var_name: str, var: Variable, value: Any) -> None:
564
589
  f"Variable '{var_name}': {input_type} default {value} > max {var.max}"
565
590
  )
566
591
 
592
+ # date/datepicker defaults: reject a datetime (a date must not carry a time),
593
+ # and parse-validate string forms as ISO so a bad default fails at `dft validate`
594
+ # instead of at query-execution time. Placed after the slider check so the
595
+ # isinstance narrowing here cannot leak into that numeric comparison (date and
596
+ # slider inputs are disjoint).
597
+ if input_type in ("date", "datepicker"):
598
+ if isinstance(value, datetime):
599
+ raise CompilationError(
600
+ f"Variable '{var_name}': {input_type} default must be a calendar "
601
+ f"date, not a datetime, got {value!r}"
602
+ )
603
+ if isinstance(value, str) and value.strip():
604
+ _validate_date_string(var_name, f"{input_type} default", value)
605
+
567
606
 
568
607
  def compute_variable_dependencies(
569
608
  variables: dict[str, Variable],
@@ -1167,7 +1167,7 @@ def _build_resolved_area_line(merged: AreaLineStyle) -> ResolvedAreaLineStyle:
1167
1167
 
1168
1168
  def _resolve_one_layer(
1169
1169
  layer: CartesianLayer,
1170
- board_rcs: ResolvedChartsStyle,
1170
+ board_style: ResolvedStyle,
1171
1171
  base_type: str,
1172
1172
  base_family_style: Any,
1173
1173
  base_query_name: str | None,
@@ -1191,11 +1191,19 @@ def _resolve_one_layer(
1191
1191
  base chart's query, so this is never an ambiguous ``None`` at render
1192
1192
  time (barring the rare inline-unnamed-query chart, whose own
1193
1193
  ``query_name`` is itself None).
1194
+
1195
+ The layer's own ``style`` patch is run through ``_with_color_tokens``
1196
+ exactly like the chart-root style patch — otherwise a palette token
1197
+ authored on a per-layer style (e.g. ``layers[].style.marks.line.stroke.
1198
+ color``) leaks through to the renderer as a literal token string instead
1199
+ of the resolved hex value.
1194
1200
  """
1201
+ board_rcs = board_style.charts
1195
1202
  axis_y = layer.axis_y if layer.axis_y is not None else LayerAxisYStyle()
1196
1203
  query_name = layer.query if layer.query is not None else base_query_name
1204
+ layer_style = _with_color_tokens(layer.style, board_style)
1197
1205
  if isinstance(layer, LineLayer):
1198
- line_marks = layer.style.marks if layer.style is not None else None
1206
+ line_marks = layer_style.marks if layer_style is not None else None
1199
1207
  line_parent = base_family_style if base_type == "line" else board_rcs.line
1200
1208
  return ResolvedLineLayer(
1201
1209
  type="line",
@@ -1217,7 +1225,7 @@ def _resolve_one_layer(
1217
1225
  query_name=query_name,
1218
1226
  )
1219
1227
  if isinstance(layer, AreaLayer):
1220
- area_marks = layer.style.marks if layer.style is not None else None
1228
+ area_marks = layer_style.marks if layer_style is not None else None
1221
1229
  area_parent = base_family_style if base_type == "area" else board_rcs.area
1222
1230
  return ResolvedAreaLayer(
1223
1231
  type="area",
@@ -1245,7 +1253,7 @@ def _resolve_one_layer(
1245
1253
  query_name=query_name,
1246
1254
  )
1247
1255
  if isinstance(layer, BarLayer):
1248
- bar_marks = layer.style.marks if layer.style is not None else None
1256
+ bar_marks = layer_style.marks if layer_style is not None else None
1249
1257
  bar_parent = base_family_style if base_type == "bar" else board_rcs.bar
1250
1258
  return ResolvedBarLayer(
1251
1259
  type="bar",
@@ -1261,7 +1269,7 @@ def _resolve_one_layer(
1261
1269
  query_name=query_name,
1262
1270
  )
1263
1271
  # ScatterLayer
1264
- scatter_marks = layer.style.marks if layer.style is not None else None
1272
+ scatter_marks = layer_style.marks if layer_style is not None else None
1265
1273
  scatter_parent = base_family_style if base_type == "scatter" else board_rcs.scatter
1266
1274
  return ResolvedScatterLayer(
1267
1275
  type="scatter",
@@ -1307,7 +1315,7 @@ def _check_layers_y_domain(
1307
1315
 
1308
1316
  def _resolve_layer_list(
1309
1317
  layers: list[CartesianLayer],
1310
- board_rcs: ResolvedChartsStyle,
1318
+ board_style: ResolvedStyle,
1311
1319
  base_type: str,
1312
1320
  base_family_style: Any,
1313
1321
  base_query_name: str | None,
@@ -1323,7 +1331,7 @@ def _resolve_layer_list(
1323
1331
  return ()
1324
1332
  return tuple(
1325
1333
  _resolve_one_layer(
1326
- layer, board_rcs, base_type, base_family_style, base_query_name
1334
+ layer, board_style, base_type, base_family_style, base_query_name
1327
1335
  )
1328
1336
  for layer in layers
1329
1337
  )
@@ -1475,7 +1483,7 @@ def _resolve_bar(
1475
1483
  ),
1476
1484
  )
1477
1485
  resolved_layers = _resolve_layer_list(
1478
- normalized.layers, board_rcs, "bar", bar, normalized.query_name
1486
+ normalized.layers, board_style, "bar", bar, normalized.query_name
1479
1487
  )
1480
1488
  _check_layers_y_domain(normalized.id, resolved_layers, ay)
1481
1489
  # Value labels track the effective measure format (bar_user_fmt), not just the
@@ -1699,7 +1707,7 @@ def _resolve_line(
1699
1707
  ),
1700
1708
  )
1701
1709
  resolved_layers = _resolve_layer_list(
1702
- normalized.layers, board_rcs, "line", line, normalized.query_name
1710
+ normalized.layers, board_style, "line", line, normalized.query_name
1703
1711
  )
1704
1712
  _check_layers_y_domain(normalized.id, resolved_layers, ay)
1705
1713
  return ResolvedLineChart(
@@ -1941,7 +1949,7 @@ def _resolve_area(
1941
1949
  ),
1942
1950
  )
1943
1951
  resolved_layers = _resolve_layer_list(
1944
- normalized.layers, board_rcs, "area", area, normalized.query_name
1952
+ normalized.layers, board_style, "area", area, normalized.query_name
1945
1953
  )
1946
1954
  _check_layers_y_domain(normalized.id, resolved_layers, ay)
1947
1955
  return ResolvedAreaChart(
@@ -2034,7 +2042,7 @@ def _resolve_scatter(
2034
2042
  style_tail = _cartesian_style_tail(board_rcs, board_style, ax, ay)
2035
2043
  style_tail["tooltip_format"] = tooltip_fmt
2036
2044
  resolved_layers = _resolve_layer_list(
2037
- normalized.layers, board_rcs, "scatter", scatter, normalized.query_name
2045
+ normalized.layers, board_style, "scatter", scatter, normalized.query_name
2038
2046
  )
2039
2047
  _check_layers_y_domain(normalized.id, resolved_layers, ay)
2040
2048
  _s = normalized.style
@@ -8,9 +8,13 @@ by both the execute and render stages.
8
8
  """
9
9
 
10
10
  import json
11
+ import math
12
+ from datetime import date, datetime
11
13
  from typing import Any
12
14
 
13
15
  from dataface.core.compile.models.face.normalized import VariableValues
16
+ from dataface.core.compile.models.variable.authored import Variable
17
+ from dataface.core.errors.execution import ExecutionError
14
18
 
15
19
 
16
20
  def parse_variable_json_strings(variables: VariableValues) -> VariableValues:
@@ -50,4 +54,173 @@ def parse_variable_json_strings(variables: VariableValues) -> VariableValues:
50
54
  return parsed
51
55
 
52
56
 
53
- __all__ = ["parse_variable_json_strings"]
57
+ # Input kinds that carry a non-string SQL type. Everything else (text, select,
58
+ # multiselect, radio, …) stays a string/list and needs no coercion.
59
+ _DATE_INPUTS = frozenset({"date", "datepicker"})
60
+ _NUMBER_INPUTS = frozenset({"number", "slider", "range"})
61
+ # Scalar typed inputs where an empty string means "unset" (→ None), like the
62
+ # renderer's absent-check. daterange is excluded — it handles endpoints itself.
63
+ _SCALAR_TYPED_INPUTS = _DATE_INPUTS | _NUMBER_INPUTS | frozenset({"checkbox"})
64
+ _BOOL_TRUE = frozenset({"true", "1", "yes", "on"})
65
+ _BOOL_FALSE = frozenset({"false", "0", "no", "off"})
66
+
67
+ # The scalar runtime domain a single-value coercer may receive. Spelled out (never
68
+ # `Any`/`object`/`| None`) so the coercers stay typed for mypy without tripping the
69
+ # dataface/core type-state freeze gate; broad enough that every isinstance below is
70
+ # meaningful and the trailing type-error raise stays reachable. Container inputs
71
+ # (daterange lists) are dispatched inline in the loop, not through these helpers.
72
+ _Coercible = str | int | float | bool | date
73
+
74
+
75
+ def parse_iso_date(value: str) -> date:
76
+ """Parse an ISO ``YYYY-MM-DD`` string to a ``date``, raising ``ValueError``.
77
+
78
+ The shared date parser for both boundaries: the compile-time default check
79
+ (wraps ValueError as CompilationError) and runtime coercion (wraps it as
80
+ ExecutionError). Keeping one parser means both agree on what a date is.
81
+ """
82
+ return date.fromisoformat(value.strip())
83
+
84
+
85
+ def coerce_variable_values(
86
+ values: VariableValues, registry: dict[str, Variable]
87
+ ) -> VariableValues:
88
+ """Coerce runtime variable values to their declared semantic Python types.
89
+
90
+ Runs at the runtime boundary (query execution), after defaults and user
91
+ values have merged. URL params, CLI, and API values arrive as strings; a
92
+ ``date`` variable's ``"2024-01-01"`` must reach SQL as a ``date`` object so
93
+ strict engines get ``DATE '2024-01-01'`` (inline path) or a typed bound
94
+ parameter — not a varchar that Trino/BigQuery refuse to compare to a date
95
+ column. The semantic type comes from the variable's ``input`` kind; values
96
+ that don't match it raise ``ExecutionError`` (fail fast, no silent fixups).
97
+
98
+ Names absent from ``registry`` (builtins, dir-navigation context) have no
99
+ declared type and pass through untouched. An empty string is treated as
100
+ unset (``None``), matching the renderer's absent-check.
101
+ """
102
+ result: VariableValues = {}
103
+ for name, value in values.items():
104
+ var = registry.get(name)
105
+ if var is None or value is None:
106
+ result[name] = value
107
+ continue
108
+ kind = var.input
109
+ if (
110
+ isinstance(value, str)
111
+ and not value.strip()
112
+ and kind in _SCALAR_TYPED_INPUTS
113
+ ):
114
+ result[name] = None # empty scalar typed input = unset
115
+ elif kind in _DATE_INPUTS:
116
+ result[name] = _coerce_date(name, value)
117
+ elif kind == "daterange":
118
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
119
+ raise ExecutionError(
120
+ f"Variable '{name}': daterange value must be [start, end], "
121
+ f"got {value!r}"
122
+ )
123
+ # Each endpoint is None or an empty string (both unset), or a
124
+ # date/ISO-string. Inlined so the None-carrying list needs no written
125
+ # ``| None`` annotation (type-state freeze gate); the element type is
126
+ # inferred from the comprehension.
127
+ result[name] = [
128
+ (
129
+ None
130
+ if endpoint is None
131
+ or (isinstance(endpoint, str) and not endpoint.strip())
132
+ else _coerce_date(name, endpoint)
133
+ )
134
+ for endpoint in value
135
+ ]
136
+ elif kind in _NUMBER_INPUTS:
137
+ result[name] = _coerce_number(name, value)
138
+ elif kind == "checkbox":
139
+ result[name] = _coerce_bool(name, value)
140
+ else:
141
+ result[name] = value
142
+ return result
143
+
144
+
145
+ def _coerce_date(name: str, value: _Coercible) -> date:
146
+ """Narrow one date/datepicker (or daterange endpoint) value to a ``date``.
147
+
148
+ Accepts what the runtime boundary produces — an ISO string or a native
149
+ ``date`` (from a compile default) — and rejects everything else, notably a
150
+ ``datetime``: it is a ``date`` subclass, so ``_to_sql_literal`` would emit
151
+ ``TIMESTAMP`` for it, the exact varchar↔date mismatch this coercion prevents.
152
+ Callers handle the empty-string-is-unset case before reaching here.
153
+ """
154
+ if isinstance(value, datetime):
155
+ raise ExecutionError(
156
+ f"Variable '{name}': date value must be a calendar date, not a "
157
+ f"datetime with a time component, got {value!r}"
158
+ )
159
+ if isinstance(value, date):
160
+ return value
161
+ if isinstance(value, str):
162
+ try:
163
+ return parse_iso_date(value)
164
+ except ValueError:
165
+ raise ExecutionError(
166
+ f"Variable '{name}': date value must be an ISO date "
167
+ f"(YYYY-MM-DD), got {value!r}"
168
+ ) from None
169
+ raise ExecutionError(
170
+ f"Variable '{name}': date value must be a string, got {type(value).__name__}"
171
+ )
172
+
173
+
174
+ def _coerce_number(name: str, value: _Coercible) -> int | float:
175
+ if isinstance(value, bool):
176
+ raise ExecutionError(
177
+ f"Variable '{name}': number value must be numeric, got a boolean"
178
+ )
179
+ if isinstance(value, int):
180
+ return value
181
+ if isinstance(value, float):
182
+ return _require_finite(name, value)
183
+ if isinstance(value, str):
184
+ return _parse_number(name, value.strip())
185
+ raise ExecutionError(
186
+ f"Variable '{name}': number value must be numeric, got {type(value).__name__}"
187
+ )
188
+
189
+
190
+ def _parse_number(name: str, text: str) -> int | float:
191
+ try:
192
+ return int(text)
193
+ except ValueError:
194
+ pass
195
+ try:
196
+ parsed = float(text)
197
+ except ValueError:
198
+ raise ExecutionError(
199
+ f"Variable '{name}': number value must be numeric, got {text!r}"
200
+ ) from None
201
+ return _require_finite(name, parsed)
202
+
203
+
204
+ def _require_finite(name: str, value: float) -> float:
205
+ if not math.isfinite(value):
206
+ raise ExecutionError(
207
+ f"Variable '{name}': number value must be finite, got {value!r}"
208
+ )
209
+ return value
210
+
211
+
212
+ def _coerce_bool(name: str, value: _Coercible) -> bool:
213
+ if isinstance(value, bool):
214
+ return value
215
+ if isinstance(value, str):
216
+ text = value.strip().lower()
217
+ if text in _BOOL_TRUE:
218
+ return True
219
+ if text in _BOOL_FALSE:
220
+ return False
221
+ raise ExecutionError(
222
+ f"Variable '{name}': checkbox value must be a boolean, got {value!r}"
223
+ )
224
+
225
+
226
+ __all__ = ["coerce_variable_values", "parse_iso_date", "parse_variable_json_strings"]
@@ -56,7 +56,10 @@ from dataface.core.compile.models.query.normalized import (
56
56
  SqlQuery,
57
57
  is_sql_query,
58
58
  )
59
- from dataface.core.compile.variables import parse_variable_json_strings
59
+ from dataface.core.compile.variables import (
60
+ coerce_variable_values,
61
+ parse_variable_json_strings,
62
+ )
60
63
  from dataface.core.errors.execution import ExecutionError, QueryError
61
64
  from dataface.core.execute.duckdb_cache import (
62
65
  compute_cache_key,
@@ -515,6 +518,14 @@ class Executor:
515
518
  "Please use a different variable name."
516
519
  )
517
520
 
521
+ # Runtime boundary: coerce to declared semantic types, same as the
522
+ # per-query path (_prepare_call), so batch-bound params are typed too.
523
+ registry = self.face.variable_registry
524
+ if registry and variables:
525
+ variables = coerce_variable_values(
526
+ parse_variable_json_strings(variables), registry
527
+ )
528
+
518
529
  # Generate batch execution plan
519
530
  plan = create_batch_execution_plan(self.face)
520
531
 
@@ -754,6 +765,12 @@ class Executor:
754
765
  all_variables.update(self.face.variable_defaults)
755
766
  parsed_variables = parse_variable_json_strings(variables or {})
756
767
  merged = {**all_variables, **parsed_variables}
768
+ # Runtime boundary: turn string inputs into their declared semantic types
769
+ # (date/number/bool) so the SQL layer binds typed literals/params. This is
770
+ # the per-query boundary execute_query and is_cached both funnel through, so
771
+ # the coerced values also key the cache identically (execute_face_batch
772
+ # coerces at its own entry).
773
+ merged = coerce_variable_values(merged, variable_registry)
757
774
  return query_name, self._get_query(query_name), merged
758
775
 
759
776
  def _lookup_cached(
@@ -123,44 +123,6 @@ def _resolve_overlap_fraction(
123
123
  return float(overlap)
124
124
 
125
125
 
126
- def _grouped_bar_chrome_px(chart: ResolvedBarChart) -> float:
127
- """Estimate pixels consumed by chrome along the horizontal bar's category axis.
128
-
129
- Used only for horizontal overlap pixel-size computation. Approximates axis
130
- labels + optional title/subtitle + legend rows so we can estimate the plot box.
131
- The estimate is intentionally rough — forgiving for 2-series thick bars.
132
- """
133
- chrome = chart.style.axis_x.label.font.size * 2.2 # axis labels + ticks
134
- legend = chart.legend
135
- if legend.visible is not False and legend.position in ("top", "bottom"):
136
- chrome += legend.label.font.size * 2.8
137
- return chrome
138
-
139
-
140
- def _grouped_bar_slot_px(
141
- extent: float,
142
- chrome_px: float,
143
- n_categories: int,
144
- n_series: int,
145
- padding_inner: float,
146
- padding_outer: float,
147
- ) -> float:
148
- """Per-series pixel slot within a category group for horizontal overlap.
149
-
150
- Mirrors Vega's band-scale step math, then divides evenly across series.
151
- Returns 0.0 when inputs can't yield a positive slot.
152
- """
153
- if extent <= 0 or n_categories <= 0 or n_series <= 0:
154
- return 0.0
155
- usable = extent - chrome_px
156
- if usable <= 0:
157
- return 0.0
158
- step = usable / (n_categories - padding_inner + 2 * padding_outer)
159
- bandwidth = step * (1.0 - padding_inner)
160
- slot = bandwidth / n_series
161
- return slot if slot > 0 else 0.0
162
-
163
-
164
126
  def _data_order_expression(color_field: str, data: list[VLDict]) -> str:
165
127
  """Build a VL calculate expression assigning each color value its first-encounter index.
166
128
 
@@ -186,8 +148,6 @@ def apply_grouped_bar_overlap(
186
148
  mark_props: VLDict,
187
149
  encoding: VLDict,
188
150
  data: list[VLDict],
189
- *,
190
- height: float | None = None,
191
151
  ) -> None:
192
152
  """Apply within-group bar spacing for grouped bars.
193
153
 
@@ -196,9 +156,11 @@ def apply_grouped_bar_overlap(
196
156
  * Full (≥1): drop the offset channel so series coincide.
197
157
  * Positive overlap (0 < f < 1): restricted to exactly 2 series.
198
158
  - Vertical: ``width: {band: 1+f}`` — band > 1 overflows in Vega.
199
- - Horizontal: explicit pixel ``size`` — Vega clamps height band > 1, so
200
- pixels are the only way to achieve true overlap. Falls back to band
201
- (touching) when no panel height is available.
159
+ - Horizontal: ``size: {expr: "(1+f) * bandwidth('yOffset')"}`` — the
160
+ ``band`` shorthand clamps height at 1 so it can't overlap, but the
161
+ ``bandwidth(scaleName)`` expression form isn't clamped and is
162
+ resolved by Vega at its own layout time, so it needs no panel
163
+ dimension and no chrome estimate.
202
164
  """
203
165
  is_horizontal = chart.orientation == "horizontal"
204
166
  offset_ch = "yOffset" if is_horizontal else "xOffset"
@@ -233,32 +195,13 @@ def apply_grouped_bar_overlap(
233
195
  mark_props["width"] = {"band": 1.0 + fraction}
234
196
  return
235
197
 
236
- # Horizontal: band clamps, so size in pixels from the estimated slot.
237
- chrome_px = _grouped_bar_chrome_px(chart)
238
- n_categories = (
239
- len({row[chart.x] for row in data if chart.x in row}) if chart.x else 0
240
- )
241
- slot = (
242
- _grouped_bar_slot_px(
243
- height,
244
- chrome_px,
245
- n_categories,
246
- n_series,
247
- GROUPED_BAR_PADDING_INNER,
248
- GROUPED_BAR_PADDING_OUTER,
249
- )
250
- if height is not None
251
- else 0.0
252
- )
253
- if slot <= 0:
254
- # No panel dimension (CLI/test callers) — best-effort band (→ touching).
255
- mark_props["height"] = {"band": 1.0 + fraction}
256
- return
257
- # Clamp so the group footprint can't exceed the category step (no spill).
258
- max_size = slot * (
259
- 1.0 + GROUPED_BAR_PADDING_INNER / (1.0 - GROUPED_BAR_PADDING_INNER) * n_series
260
- )
261
- mark_props["size"] = min(slot * (1.0 + fraction), max_size)
198
+ # Horizontal: band clamps, so size comes from Vega's own resolved
199
+ # per-series offset-scale width. paddingInner=0 on the offset scale
200
+ # (set above) makes bandwidth('yOffset') == total_step / n_series, and
201
+ # fraction < 1 here keeps (1+fraction)*bandwidth under 2*bandwidth ==
202
+ # total_step — the group footprint can't spill into a neighboring
203
+ # category by construction, no clamp needed.
204
+ mark_props["size"] = {"expr": f"{1.0 + fraction} * bandwidth('yOffset')"}
262
205
  # Drop the band-relative height (from bar_mark_to_vl / theme band_width) so
263
206
  # the explicit pixel size is the sole height control — band and size conflict.
264
207
  mark_props.pop("height", None)
@@ -26,7 +26,8 @@ class GroupedBarOverlapFeature:
26
26
 
27
27
  The emitter sets up the offset encoding; this feature:
28
28
  1. Sets the outer categorical axis paddingInner/paddingOuter (group gaps).
29
- 2. Applies mark sizing using box.height from the render-time slot geometry.
29
+ 2. Applies mark sizing (band shorthand, or a bandwidth() expression for
30
+ the horizontal-overlap case — resolved by Vega itself, not box geometry).
30
31
  """
31
32
 
32
33
  def applies_to(self, chart: ResolvedChart) -> bool:
@@ -64,6 +65,5 @@ class GroupedBarOverlapFeature:
64
65
  spec.mark_props,
65
66
  spec.encoding,
66
67
  chart_rows(chart, datasets),
67
- height=box.height,
68
68
  )
69
69
  return spec
@@ -172,7 +172,13 @@ def _render_vl_artifact(
172
172
  vl["$df_target_height"] = effective_height
173
173
  if len(vl["hconcat"]) > 1:
174
174
  vl["hconcat"][1]["height"] = effective_height
175
- apply_title_overflow_to_spec(size_target, resolved_style.charts.title)
175
+ if "hconcat" not in vl:
176
+ # hconcat's title wrap is deferred to render_vega_spec: vl-convert
177
+ # ignores autosize:fit on concat children, so pane[0].width here is
178
+ # still the full column width — wrapping against it now overstates
179
+ # the room the title actually has. render_vega_spec wraps it after
180
+ # _correct_concat_overshoot shrinks pane[0] to its real width.
181
+ apply_title_overflow_to_spec(size_target, resolved_style.charts.title)
176
182
  _trace_vl_spec(resolved.id, "v2", vl)
177
183
  return RenderArtifact(kind="vega_spec", payload=vl)
178
184