From 83265f61e83b2627a4d34bf957d57ca27c5cf594 Mon Sep 17 00:00:00 2001 From: ghostiee-11 <168410465+ghostiee-11@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:12:30 +0530 Subject: [PATCH 1/2] Fix export to image not capturing current view position (#1773) When exporting VegaLite charts as PNG/JPEG/SVG/PDF, the exported image now matches the browser viewport instead of rendering at default bounds. Three improvements: - Capture zoom/pan bounds from the live panel selection state and inject them into the export spec (via selection value or scale.domain) - Scale export dimensions to at least 1600x800 to match typical browser container sizes, preventing geographic map projections from cropping - Set width/height directly on the Panel Vega pane before export so Panel Vega.export() uses the correct dimensions --- lumen/ai/editors.py | 188 ++++++++++++++++++- lumen/tests/ai/test_editors.py | 333 ++++++++++++++++++++++++++++++++- 2 files changed, 515 insertions(+), 6 deletions(-) diff --git a/lumen/ai/editors.py b/lumen/ai/editors.py index 1bcb6361c..4d0320a38 100644 --- a/lumen/ai/editors.py +++ b/lumen/ai/editors.py @@ -290,6 +290,152 @@ class VegaLiteEditor(LumenEditor): _controls = [RetryControls, AnnotationControls, CopyControls] _label = "Plot" + # Default export dimensions. These approximate a typical browser viewport + # and are used when the spec uses container sizing or the actual rendered + # dimensions cannot be determined from the live panel. + _export_width = param.Integer(default=1600, precedence=-1, doc=""" + Minimum width for exported images. Ensures the export is wide + enough to show full chart content including legends.""") + _export_height = param.Integer(default=800, precedence=-1, doc=""" + Minimum height for exported images. Ensures geographic maps + have enough vertical space to show the full projection.""") + + def _get_current_bounds(self) -> dict | None: + """Extract current zoom/pan bounds from the live browser-rendered panel. + + Returns a dict mapping field names to [min, max] intervals, + e.g. {'temperature': [10, 30], 'date': ['2024-01-01', '2024-06-01']}, + or None if no zoom state is available. + """ + panel = getattr(self.component, '_panel', None) + if panel is None or not hasattr(panel, 'selection') or panel.selection is None: + return None + bounds = {} + for param_name in panel.selection.param: + if param_name == 'name': + continue + value = getattr(panel.selection, param_name) + if isinstance(value, dict): + bounds.update(value) + return bounds if bounds else None + + @staticmethod + def _set_selection_value(spec: dict, bounds: dict) -> bool: + """Set the value of an interval selection bound to scales. + + This works for standard x/y charts (scatter, bar, line, etc.) + where an interval selection with bind="scales" enables zoom/pan. + Vega-Lite uses the selection value to initialize the scale domains + at render time, reproducing the user's zoomed view. + + Note: Vega-Lite does not support bind="scales" with geographic + projections, so geographic maps are handled separately via + dimension preservation in export(). + + Returns True if a matching selection was found and updated. + """ + if not bounds: + return False + params = spec.get('params', []) + if not isinstance(params, list): + return False + for i, p in enumerate(params): + if not isinstance(p, dict): + continue + select = p.get('select', {}) + sel_type = select if isinstance(select, str) else select.get('type', '') + if sel_type == 'interval' and p.get('bind') == 'scales': + params = list(params) + params[i] = dict(p, value=dict(bounds)) + spec['params'] = params + return True + return False + + @staticmethod + def _inject_encoding_domains(spec: dict, bounds: dict) -> None: + """Fallback: inject scale.domain on matching encoding channels. + + Modifies spec in place. Handles x, y, x2, y2 channels. + Does not handle longitude/latitude (they use projections, not scales). + """ + def inject_encoding(enc: dict) -> dict: + enc = dict(enc) + for channel in ('x', 'y', 'x2', 'y2'): + if channel not in enc: + continue + ch_spec = enc[channel] + field = ch_spec.get('field') + if field and field in bounds: + ch_spec = dict(ch_spec) + scale = dict(ch_spec.get('scale', {})) + scale['domain'] = list(bounds[field]) + ch_spec['scale'] = scale + enc[channel] = ch_spec + return enc + + if 'encoding' in spec: + spec['encoding'] = inject_encoding(spec['encoding']) + if 'layer' in spec: + spec['layer'] = [ + dict(layer, encoding=inject_encoding(layer['encoding'])) + if 'encoding' in layer else layer + for layer in spec['layer'] + ] + + @staticmethod + def _apply_bounds_to_spec(spec: dict, bounds: dict) -> dict: + """Inject zoom/pan bounds into a Vega-Lite spec. + + Uses two strategies for standard x/y charts: + 1. Primary: Set the value of an interval selection with bind="scales". + 2. Fallback: Inject scale.domain on encoding channels directly. + + For geographic maps with projections, zoom/pan state is preserved + by matching the export dimensions to the live panel dimensions + (handled in export(), not here). + + Parameters + ---------- + spec : dict + The Vega-Lite specification. + bounds : dict + Field-name-to-[min, max] mapping from the live selection. + + Returns + ------- + dict + Modified spec with current view bounds applied. + """ + spec = dict(spec) + + # Strategy 1: Set selection parameter value (universal approach) + if VegaLiteEditor._set_selection_value(spec, bounds): + return spec + + # Also check inside layers for selection params + if 'layer' in spec: + for i, layer in enumerate(spec['layer']): + if not isinstance(layer, dict): + continue + layer = dict(layer) + if VegaLiteEditor._set_selection_value(layer, bounds): + layers = list(spec['layer']) + layers[i] = layer + spec['layer'] = layers + return spec + + # Strategy 2: Fallback to scale.domain injection on encodings + VegaLiteEditor._inject_encoding_domains(spec, bounds) + + # Recurse into concatenated specs + for key in ('hconcat', 'vconcat', 'concat'): + if key in spec: + spec[key] = [ + VegaLiteEditor._apply_bounds_to_spec(sub, bounds) + for sub in spec[key] + ] + return spec + def export(self, fmt: str) -> StringIO | BytesIO: ret = super().export(fmt) if ret is not None: @@ -299,13 +445,45 @@ def export(self, fmt: str) -> StringIO | BytesIO: render_fmt = "png" if fmt in self._pillow_formats else fmt kwargs = {"scale": 2} if render_fmt in ("png", "jpeg", "pdf") else {} + # Capture zoom/pan bounds from live panel + # BEFORE param.update replaces the component + bounds = self._get_current_bounds() + spec = load_yaml(self.spec) - if spec.get("width") == "container" or "width" not in spec: - spec["width"] = 800 - if spec.get("height") == "container" or "height" not in spec: - spec["height"] = 400 + + # Resolve dimensions for export rendering. + # The browser renders charts stretched to fill the container + # (via sizing_mode='stretch_width'/'stretch_both'), so both width + # and height in the browser are typically larger than what the spec + # declares. Ensure export dimensions are at least _export_width and + # _export_height to approximate the browser viewport. + spec_width = spec.get("width") + spec_height = spec.get("height") + if isinstance(spec_width, (int, float)) and spec_width != "container": + spec["width"] = max(int(spec_width), self._export_width) + else: + spec["width"] = self._export_width + if isinstance(spec_height, (int, float)) and spec_height != "container": + spec["height"] = max(int(spec_height), self._export_height) + else: + spec["height"] = self._export_height + + # Inject current view bounds so export reflects zoomed view + # (works for standard x/y charts with interval selections) + if bounds: + spec = self._apply_bounds_to_spec(spec, bounds) + + export_width = spec["width"] + export_height = spec["height"] with self.param.update(spec=dump_yaml(spec)): - out = self.component.get_panel().export(render_fmt, **kwargs) + panel = self.component.get_panel() + # Set dimensions directly on the pane so Panel's Vega.export() + # uses them instead of its own defaults + if hasattr(panel, 'width'): + panel.width = export_width + if hasattr(panel, 'height'): + panel.height = export_height + out = panel.export(render_fmt, **kwargs) if fmt in self._pillow_formats: img = Image.open(BytesIO(out)) diff --git a/lumen/tests/ai/test_editors.py b/lumen/tests/ai/test_editors.py index a4db67120..3ed1c2c7b 100644 --- a/lumen/tests/ai/test_editors.py +++ b/lumen/tests/ai/test_editors.py @@ -146,5 +146,336 @@ def test_class_name_with_numbers(): """Test handling of numbers in class names.""" class Editor2D(LumenEditor): pass - + assert Editor2D._class_name_to_download_filename("png") == "editor2_d.png" + + +# --- Tests for zoom/pan bounds capture in export (issue #1773) --- + +def _make_mock_selection(**fields): + """Create a mock selection object with interval selection params.""" + params = { + name: param.Dict(default=None) for name in fields + } + sel = type('Selection', (param.Parameterized,), params)() + for name, value in fields.items(): + setattr(sel, name, value) + return sel + + +class TestGetCurrentBounds: + + def test_returns_bounds_from_live_panel(self, monkeypatch): + monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) + monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) + + mock_panel = type('MockPanel', (), {'selection': None})() + mock_panel.selection = _make_mock_selection( + brush={'A': [2, 8], 'B': [10, 50]} + ) + + component = MockVegaComponent() + component._panel = mock_panel + + editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) + editor.component = component # restore after __init__ may have replaced it + + assert editor._get_current_bounds() == {'A': [2, 8], 'B': [10, 50]} + + def test_returns_none_when_no_panel(self, monkeypatch): + monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) + monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) + + component = MockVegaComponent() + component._panel = None + + editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) + editor.component = component + + assert editor._get_current_bounds() is None + + def test_returns_none_when_no_selection(self, monkeypatch): + monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) + monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) + + mock_panel = type('MockPanel', (), {'selection': None})() + + component = MockVegaComponent() + component._panel = mock_panel + + editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) + editor.component = component + + assert editor._get_current_bounds() is None + + def test_ignores_non_dict_selections(self, monkeypatch): + """Point selections (lists) should be ignored, only interval (dict) kept.""" + monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) + monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) + + params = { + 'zoom': param.Dict(default=None), + 'click': param.List(default=None), + } + sel = type('Selection', (param.Parameterized,), params)() + sel.zoom = {'A': [1, 5]} + sel.click = [1, 2, 3] + + mock_panel = type('MockPanel', (), {'selection': sel})() + + component = MockVegaComponent() + component._panel = mock_panel + + editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) + editor.component = component + + assert editor._get_current_bounds() == {'A': [1, 5]} + + +class TestSetSelectionValue: + """Tests for the primary strategy: injecting bounds via selection param value.""" + + def test_sets_value_on_interval_selection_with_bind_scales(self): + spec = { + 'params': [ + {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} + ], + 'encoding': {'x': {'field': 'A'}, 'y': {'field': 'B'}}, + } + bounds = {'A': [2, 8], 'B': [10, 50]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert result['params'][0]['value'] == {'A': [2, 8], 'B': [10, 50]} + # Should NOT also inject scale.domain (selection value is sufficient) + assert 'scale' not in result['encoding']['x'] + + def test_geographic_map_no_selection_no_mutation(self): + """Geographic maps don't use bind='scales' (Vega-Lite doesn't support it + with projections). Bounds injection is a no-op; the fix for geographic + maps is dimension preservation in export().""" + spec = { + 'projection': {'type': 'equalEarth'}, + 'layer': [ + {'mark': 'geoshape'}, + { + 'mark': 'circle', + 'encoding': { + 'longitude': {'field': 'Longitude', 'type': 'quantitative'}, + 'latitude': {'field': 'Latitude', 'type': 'quantitative'}, + } + } + ] + } + bounds = {'Longitude': [-130, -60], 'Latitude': [10, 55]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + # No selection param to set, no x/y encoding to inject into + # Spec should pass through unchanged (geographic fix is via dimensions) + assert result['projection'] == {'type': 'equalEarth'} + assert 'params' not in result + assert 'scale' not in result['layer'][1]['encoding'].get('longitude', {}) + + def test_ignores_non_interval_selections(self): + spec = { + 'params': [ + {'name': 'click', 'select': {'type': 'point'}}, + ], + 'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, + } + bounds = {'A': [2, 8]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + # Should NOT set value on point selection + assert 'value' not in result['params'][0] + # Should fall back to scale.domain + assert result['encoding']['x']['scale']['domain'] == [2, 8] + + def test_ignores_interval_without_bind_scales(self): + spec = { + 'params': [ + {'name': 'brush', 'select': {'type': 'interval'}}, + ], + 'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, + } + bounds = {'A': [2, 8]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + # No bind="scales" → not a zoom selection, fall back + assert 'value' not in result['params'][0] + assert result['encoding']['x']['scale']['domain'] == [2, 8] + + def test_selection_in_layer(self): + """Selection defined inside a layer, not at top level.""" + spec = { + 'layer': [ + { + 'params': [ + {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} + ], + 'mark': 'point', + 'encoding': {'x': {'field': 'A'}, 'y': {'field': 'B'}}, + }, + {'mark': 'line', 'encoding': {'x': {'field': 'A'}, 'y': {'field': 'C'}}}, + ] + } + bounds = {'A': [1, 5], 'B': [10, 30]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert result['layer'][0]['params'][0]['value'] == {'A': [1, 5], 'B': [10, 30]} + + def test_selection_string_type(self): + """Vega-Lite also accepts select as a string shorthand.""" + spec = { + 'params': [ + {'name': 'zoom', 'select': 'interval', 'bind': 'scales'} + ], + } + bounds = {'A': [2, 8]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert result['params'][0]['value'] == {'A': [2, 8]} + + def test_does_not_mutate_original(self): + spec = { + 'params': [ + {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} + ], + } + bounds = {'A': [2, 8]} + VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert 'value' not in spec['params'][0] + + def test_empty_bounds_no_change(self): + spec = { + 'params': [ + {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} + ], + } + result = VegaLiteEditor._apply_bounds_to_spec(spec, {}) + + assert 'value' not in result['params'][0] + + +class TestEncodingDomainFallback: + """Tests for the fallback strategy: scale.domain injection on encodings.""" + + def test_simple_spec_without_selection(self): + spec = { + 'encoding': { + 'x': {'field': 'A', 'type': 'quantitative'}, + 'y': {'field': 'B', 'type': 'quantitative'}, + } + } + bounds = {'A': [2, 8], 'B': [10, 50]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert result['encoding']['x']['scale']['domain'] == [2, 8] + assert result['encoding']['y']['scale']['domain'] == [10, 50] + + def test_preserves_existing_scale_properties(self): + spec = { + 'encoding': { + 'x': {'field': 'A', 'type': 'quantitative', 'scale': {'type': 'log'}}, + } + } + bounds = {'A': [1, 100]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert result['encoding']['x']['scale']['domain'] == [1, 100] + assert result['encoding']['x']['scale']['type'] == 'log' + + def test_layered_spec_without_selection(self): + spec = { + 'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, + 'layer': [ + {'encoding': {'y': {'field': 'B', 'type': 'quantitative'}}, 'mark': 'line'}, + {'encoding': {'y': {'field': 'C', 'type': 'quantitative'}}, 'mark': 'point'}, + ] + } + bounds = {'A': [1, 5], 'B': [10, 20]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert result['encoding']['x']['scale']['domain'] == [1, 5] + assert result['layer'][0]['encoding']['y']['scale']['domain'] == [10, 20] + assert 'scale' not in result['layer'][1]['encoding']['y'] + + def test_no_matching_fields(self): + spec = { + 'encoding': { + 'x': {'field': 'A', 'type': 'quantitative'}, + 'y': {'field': 'B', 'type': 'quantitative'}, + } + } + bounds = {'Z': [0, 100]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert 'scale' not in result['encoding']['x'] + assert 'scale' not in result['encoding']['y'] + + def test_concat_spec(self): + spec = { + 'hconcat': [ + {'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, 'mark': 'bar'}, + {'encoding': {'x': {'field': 'B', 'type': 'quantitative'}}, 'mark': 'bar'}, + ] + } + bounds = {'A': [0, 10]} + result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert result['hconcat'][0]['encoding']['x']['scale']['domain'] == [0, 10] + assert 'scale' not in result['hconcat'][1]['encoding']['x'] + + def test_does_not_mutate_original(self): + spec = { + 'encoding': { + 'x': {'field': 'A', 'type': 'quantitative'}, + } + } + bounds = {'A': [2, 8]} + VegaLiteEditor._apply_bounds_to_spec(spec, bounds) + + assert 'scale' not in spec['encoding']['x'] + + +class TestExportWithBounds: + + def test_export_injects_bounds(self, monkeypatch, mock_panel): + """Verify export() reads bounds and produces output without error.""" + monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) + monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) + + component = MockVegaComponent(_mock_panel=mock_panel) + editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) + + # Simulate zoom state on the live panel + sel = _make_mock_selection(zoom={'A': [2, 5], 'B': [10, 30]}) + live_panel = type('LivePanel', (), {'selection': sel, 'width': None, 'height': None})() + editor.component._panel = live_panel + + result = editor.export('png') + assert isinstance(result, BytesIO) + assert len(mock_panel.calls) == 1 + + def test_export_without_bounds_unchanged(self, vegalite_editor, mock_panel): + """Verify export works normally when no zoom state exists.""" + result = vegalite_editor.export('png') + assert isinstance(result, BytesIO) + assert len(mock_panel.calls) == 1 + + def test_export_uses_live_panel_dimensions(self, monkeypatch, mock_panel): + """Verify export uses live panel width/height when available.""" + monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) + monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) + + component = MockVegaComponent(_mock_panel=mock_panel) + editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) + + live_panel = type('LivePanel', (), { + 'selection': None, 'width': 1200, 'height': 600 + })() + editor.component._panel = live_panel + + result = editor.export('png') + assert isinstance(result, BytesIO) + assert len(mock_panel.calls) == 1 From 24a982f1f892728fd1dd031a9f1f89ad3c153a7d Mon Sep 17 00:00:00 2001 From: ghostiee-11 <168410465+ghostiee-11@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:10:04 +0530 Subject: [PATCH 2/2] Simplify export fix to only adjust default dimensions (#1773) Strip overengineered bounds-capture logic per review feedback. The core issue was container-sized specs exporting at 800x400. Fix uses 1600x800 viewport defaults instead. Removes all getattr/hasattr guards, bounds injection, and recursion helpers. --- lumen/ai/editors.py | 188 +---------------- lumen/tests/ai/test_editors.py | 355 +++------------------------------ 2 files changed, 34 insertions(+), 509 deletions(-) diff --git a/lumen/ai/editors.py b/lumen/ai/editors.py index 4d0320a38..a5a737443 100644 --- a/lumen/ai/editors.py +++ b/lumen/ai/editors.py @@ -290,152 +290,6 @@ class VegaLiteEditor(LumenEditor): _controls = [RetryControls, AnnotationControls, CopyControls] _label = "Plot" - # Default export dimensions. These approximate a typical browser viewport - # and are used when the spec uses container sizing or the actual rendered - # dimensions cannot be determined from the live panel. - _export_width = param.Integer(default=1600, precedence=-1, doc=""" - Minimum width for exported images. Ensures the export is wide - enough to show full chart content including legends.""") - _export_height = param.Integer(default=800, precedence=-1, doc=""" - Minimum height for exported images. Ensures geographic maps - have enough vertical space to show the full projection.""") - - def _get_current_bounds(self) -> dict | None: - """Extract current zoom/pan bounds from the live browser-rendered panel. - - Returns a dict mapping field names to [min, max] intervals, - e.g. {'temperature': [10, 30], 'date': ['2024-01-01', '2024-06-01']}, - or None if no zoom state is available. - """ - panel = getattr(self.component, '_panel', None) - if panel is None or not hasattr(panel, 'selection') or panel.selection is None: - return None - bounds = {} - for param_name in panel.selection.param: - if param_name == 'name': - continue - value = getattr(panel.selection, param_name) - if isinstance(value, dict): - bounds.update(value) - return bounds if bounds else None - - @staticmethod - def _set_selection_value(spec: dict, bounds: dict) -> bool: - """Set the value of an interval selection bound to scales. - - This works for standard x/y charts (scatter, bar, line, etc.) - where an interval selection with bind="scales" enables zoom/pan. - Vega-Lite uses the selection value to initialize the scale domains - at render time, reproducing the user's zoomed view. - - Note: Vega-Lite does not support bind="scales" with geographic - projections, so geographic maps are handled separately via - dimension preservation in export(). - - Returns True if a matching selection was found and updated. - """ - if not bounds: - return False - params = spec.get('params', []) - if not isinstance(params, list): - return False - for i, p in enumerate(params): - if not isinstance(p, dict): - continue - select = p.get('select', {}) - sel_type = select if isinstance(select, str) else select.get('type', '') - if sel_type == 'interval' and p.get('bind') == 'scales': - params = list(params) - params[i] = dict(p, value=dict(bounds)) - spec['params'] = params - return True - return False - - @staticmethod - def _inject_encoding_domains(spec: dict, bounds: dict) -> None: - """Fallback: inject scale.domain on matching encoding channels. - - Modifies spec in place. Handles x, y, x2, y2 channels. - Does not handle longitude/latitude (they use projections, not scales). - """ - def inject_encoding(enc: dict) -> dict: - enc = dict(enc) - for channel in ('x', 'y', 'x2', 'y2'): - if channel not in enc: - continue - ch_spec = enc[channel] - field = ch_spec.get('field') - if field and field in bounds: - ch_spec = dict(ch_spec) - scale = dict(ch_spec.get('scale', {})) - scale['domain'] = list(bounds[field]) - ch_spec['scale'] = scale - enc[channel] = ch_spec - return enc - - if 'encoding' in spec: - spec['encoding'] = inject_encoding(spec['encoding']) - if 'layer' in spec: - spec['layer'] = [ - dict(layer, encoding=inject_encoding(layer['encoding'])) - if 'encoding' in layer else layer - for layer in spec['layer'] - ] - - @staticmethod - def _apply_bounds_to_spec(spec: dict, bounds: dict) -> dict: - """Inject zoom/pan bounds into a Vega-Lite spec. - - Uses two strategies for standard x/y charts: - 1. Primary: Set the value of an interval selection with bind="scales". - 2. Fallback: Inject scale.domain on encoding channels directly. - - For geographic maps with projections, zoom/pan state is preserved - by matching the export dimensions to the live panel dimensions - (handled in export(), not here). - - Parameters - ---------- - spec : dict - The Vega-Lite specification. - bounds : dict - Field-name-to-[min, max] mapping from the live selection. - - Returns - ------- - dict - Modified spec with current view bounds applied. - """ - spec = dict(spec) - - # Strategy 1: Set selection parameter value (universal approach) - if VegaLiteEditor._set_selection_value(spec, bounds): - return spec - - # Also check inside layers for selection params - if 'layer' in spec: - for i, layer in enumerate(spec['layer']): - if not isinstance(layer, dict): - continue - layer = dict(layer) - if VegaLiteEditor._set_selection_value(layer, bounds): - layers = list(spec['layer']) - layers[i] = layer - spec['layer'] = layers - return spec - - # Strategy 2: Fallback to scale.domain injection on encodings - VegaLiteEditor._inject_encoding_domains(spec, bounds) - - # Recurse into concatenated specs - for key in ('hconcat', 'vconcat', 'concat'): - if key in spec: - spec[key] = [ - VegaLiteEditor._apply_bounds_to_spec(sub, bounds) - for sub in spec[key] - ] - return spec - def export(self, fmt: str) -> StringIO | BytesIO: ret = super().export(fmt) if ret is not None: @@ -445,45 +299,13 @@ def export(self, fmt: str) -> StringIO | BytesIO: render_fmt = "png" if fmt in self._pillow_formats else fmt kwargs = {"scale": 2} if render_fmt in ("png", "jpeg", "pdf") else {} - # Capture zoom/pan bounds from live panel - # BEFORE param.update replaces the component - bounds = self._get_current_bounds() - spec = load_yaml(self.spec) - - # Resolve dimensions for export rendering. - # The browser renders charts stretched to fill the container - # (via sizing_mode='stretch_width'/'stretch_both'), so both width - # and height in the browser are typically larger than what the spec - # declares. Ensure export dimensions are at least _export_width and - # _export_height to approximate the browser viewport. - spec_width = spec.get("width") - spec_height = spec.get("height") - if isinstance(spec_width, (int, float)) and spec_width != "container": - spec["width"] = max(int(spec_width), self._export_width) - else: - spec["width"] = self._export_width - if isinstance(spec_height, (int, float)) and spec_height != "container": - spec["height"] = max(int(spec_height), self._export_height) - else: - spec["height"] = self._export_height - - # Inject current view bounds so export reflects zoomed view - # (works for standard x/y charts with interval selections) - if bounds: - spec = self._apply_bounds_to_spec(spec, bounds) - - export_width = spec["width"] - export_height = spec["height"] + if spec.get("width") == "container" or "width" not in spec: + spec["width"] = 1600 + if spec.get("height") == "container" or "height" not in spec: + spec["height"] = 800 with self.param.update(spec=dump_yaml(spec)): - panel = self.component.get_panel() - # Set dimensions directly on the pane so Panel's Vega.export() - # uses them instead of its own defaults - if hasattr(panel, 'width'): - panel.width = export_width - if hasattr(panel, 'height'): - panel.height = export_height - out = panel.export(render_fmt, **kwargs) + out = self.component.get_panel().export(render_fmt, **kwargs) if fmt in self._pillow_formats: img = Image.open(BytesIO(out)) diff --git a/lumen/tests/ai/test_editors.py b/lumen/tests/ai/test_editors.py index 3ed1c2c7b..a5f474766 100644 --- a/lumen/tests/ai/test_editors.py +++ b/lumen/tests/ai/test_editors.py @@ -150,332 +150,35 @@ class Editor2D(LumenEditor): assert Editor2D._class_name_to_download_filename("png") == "editor2_d.png" -# --- Tests for zoom/pan bounds capture in export (issue #1773) --- - -def _make_mock_selection(**fields): - """Create a mock selection object with interval selection params.""" - params = { - name: param.Dict(default=None) for name in fields - } - sel = type('Selection', (param.Parameterized,), params)() - for name, value in fields.items(): - setattr(sel, name, value) - return sel - +def test_vegalite_export_container_width_uses_viewport_default(vegalite_editor, mock_panel): + """Container-sized specs should export at 1600x800, not 800x400.""" + result = vegalite_editor.export('png') + assert isinstance(result, BytesIO) + assert mock_panel.calls[-1] == ("png", {"scale": 2}) -class TestGetCurrentBounds: - def test_returns_bounds_from_live_panel(self, monkeypatch): - monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) - monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) +def test_vegalite_export_preserves_explicit_dimensions(monkeypatch, mock_panel): + """Specs with explicit numeric width/height keep them unchanged.""" + monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) + monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) - mock_panel = type('MockPanel', (), {'selection': None})() - mock_panel.selection = _make_mock_selection( - brush={'A': [2, 8], 'B': [10, 50]} - ) - - component = MockVegaComponent() - component._panel = mock_panel - - editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) - editor.component = component # restore after __init__ may have replaced it - - assert editor._get_current_bounds() == {'A': [2, 8], 'B': [10, 50]} - - def test_returns_none_when_no_panel(self, monkeypatch): - monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) - monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) - - component = MockVegaComponent() - component._panel = None - - editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) - editor.component = component - - assert editor._get_current_bounds() is None - - def test_returns_none_when_no_selection(self, monkeypatch): - monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) - monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) - - mock_panel = type('MockPanel', (), {'selection': None})() - - component = MockVegaComponent() - component._panel = mock_panel - - editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) - editor.component = component - - assert editor._get_current_bounds() is None - - def test_ignores_non_dict_selections(self, monkeypatch): - """Point selections (lists) should be ignored, only interval (dict) kept.""" - monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) - monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) - - params = { - 'zoom': param.Dict(default=None), - 'click': param.List(default=None), - } - sel = type('Selection', (param.Parameterized,), params)() - sel.zoom = {'A': [1, 5]} - sel.click = [1, 2, 3] - - mock_panel = type('MockPanel', (), {'selection': sel})() - - component = MockVegaComponent() - component._panel = mock_panel - - editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) - editor.component = component - - assert editor._get_current_bounds() == {'A': [1, 5]} - - -class TestSetSelectionValue: - """Tests for the primary strategy: injecting bounds via selection param value.""" - - def test_sets_value_on_interval_selection_with_bind_scales(self): - spec = { - 'params': [ - {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} - ], - 'encoding': {'x': {'field': 'A'}, 'y': {'field': 'B'}}, - } - bounds = {'A': [2, 8], 'B': [10, 50]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert result['params'][0]['value'] == {'A': [2, 8], 'B': [10, 50]} - # Should NOT also inject scale.domain (selection value is sufficient) - assert 'scale' not in result['encoding']['x'] - - def test_geographic_map_no_selection_no_mutation(self): - """Geographic maps don't use bind='scales' (Vega-Lite doesn't support it - with projections). Bounds injection is a no-op; the fix for geographic - maps is dimension preservation in export().""" - spec = { - 'projection': {'type': 'equalEarth'}, - 'layer': [ - {'mark': 'geoshape'}, - { - 'mark': 'circle', - 'encoding': { - 'longitude': {'field': 'Longitude', 'type': 'quantitative'}, - 'latitude': {'field': 'Latitude', 'type': 'quantitative'}, - } - } - ] - } - bounds = {'Longitude': [-130, -60], 'Latitude': [10, 55]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - # No selection param to set, no x/y encoding to inject into - # Spec should pass through unchanged (geographic fix is via dimensions) - assert result['projection'] == {'type': 'equalEarth'} - assert 'params' not in result - assert 'scale' not in result['layer'][1]['encoding'].get('longitude', {}) - - def test_ignores_non_interval_selections(self): - spec = { - 'params': [ - {'name': 'click', 'select': {'type': 'point'}}, - ], - 'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, - } - bounds = {'A': [2, 8]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - # Should NOT set value on point selection - assert 'value' not in result['params'][0] - # Should fall back to scale.domain - assert result['encoding']['x']['scale']['domain'] == [2, 8] - - def test_ignores_interval_without_bind_scales(self): - spec = { - 'params': [ - {'name': 'brush', 'select': {'type': 'interval'}}, - ], - 'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, - } - bounds = {'A': [2, 8]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - # No bind="scales" → not a zoom selection, fall back - assert 'value' not in result['params'][0] - assert result['encoding']['x']['scale']['domain'] == [2, 8] - - def test_selection_in_layer(self): - """Selection defined inside a layer, not at top level.""" - spec = { - 'layer': [ - { - 'params': [ - {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} - ], - 'mark': 'point', - 'encoding': {'x': {'field': 'A'}, 'y': {'field': 'B'}}, - }, - {'mark': 'line', 'encoding': {'x': {'field': 'A'}, 'y': {'field': 'C'}}}, - ] - } - bounds = {'A': [1, 5], 'B': [10, 30]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert result['layer'][0]['params'][0]['value'] == {'A': [1, 5], 'B': [10, 30]} - - def test_selection_string_type(self): - """Vega-Lite also accepts select as a string shorthand.""" - spec = { - 'params': [ - {'name': 'zoom', 'select': 'interval', 'bind': 'scales'} - ], - } - bounds = {'A': [2, 8]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert result['params'][0]['value'] == {'A': [2, 8]} - - def test_does_not_mutate_original(self): - spec = { - 'params': [ - {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} - ], - } - bounds = {'A': [2, 8]} - VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert 'value' not in spec['params'][0] - - def test_empty_bounds_no_change(self): - spec = { - 'params': [ - {'name': 'zoom', 'select': {'type': 'interval'}, 'bind': 'scales'} - ], - } - result = VegaLiteEditor._apply_bounds_to_spec(spec, {}) - - assert 'value' not in result['params'][0] - - -class TestEncodingDomainFallback: - """Tests for the fallback strategy: scale.domain injection on encodings.""" - - def test_simple_spec_without_selection(self): - spec = { - 'encoding': { - 'x': {'field': 'A', 'type': 'quantitative'}, - 'y': {'field': 'B', 'type': 'quantitative'}, - } - } - bounds = {'A': [2, 8], 'B': [10, 50]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert result['encoding']['x']['scale']['domain'] == [2, 8] - assert result['encoding']['y']['scale']['domain'] == [10, 50] - - def test_preserves_existing_scale_properties(self): - spec = { - 'encoding': { - 'x': {'field': 'A', 'type': 'quantitative', 'scale': {'type': 'log'}}, - } - } - bounds = {'A': [1, 100]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert result['encoding']['x']['scale']['domain'] == [1, 100] - assert result['encoding']['x']['scale']['type'] == 'log' - - def test_layered_spec_without_selection(self): - spec = { - 'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, - 'layer': [ - {'encoding': {'y': {'field': 'B', 'type': 'quantitative'}}, 'mark': 'line'}, - {'encoding': {'y': {'field': 'C', 'type': 'quantitative'}}, 'mark': 'point'}, - ] - } - bounds = {'A': [1, 5], 'B': [10, 20]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert result['encoding']['x']['scale']['domain'] == [1, 5] - assert result['layer'][0]['encoding']['y']['scale']['domain'] == [10, 20] - assert 'scale' not in result['layer'][1]['encoding']['y'] - - def test_no_matching_fields(self): - spec = { - 'encoding': { - 'x': {'field': 'A', 'type': 'quantitative'}, - 'y': {'field': 'B', 'type': 'quantitative'}, - } - } - bounds = {'Z': [0, 100]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert 'scale' not in result['encoding']['x'] - assert 'scale' not in result['encoding']['y'] - - def test_concat_spec(self): - spec = { - 'hconcat': [ - {'encoding': {'x': {'field': 'A', 'type': 'quantitative'}}, 'mark': 'bar'}, - {'encoding': {'x': {'field': 'B', 'type': 'quantitative'}}, 'mark': 'bar'}, - ] - } - bounds = {'A': [0, 10]} - result = VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert result['hconcat'][0]['encoding']['x']['scale']['domain'] == [0, 10] - assert 'scale' not in result['hconcat'][1]['encoding']['x'] - - def test_does_not_mutate_original(self): - spec = { - 'encoding': { - 'x': {'field': 'A', 'type': 'quantitative'}, - } - } - bounds = {'A': [2, 8]} - VegaLiteEditor._apply_bounds_to_spec(spec, bounds) - - assert 'scale' not in spec['encoding']['x'] - - -class TestExportWithBounds: - - def test_export_injects_bounds(self, monkeypatch, mock_panel): - """Verify export() reads bounds and produces output without error.""" - monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) - monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) - - component = MockVegaComponent(_mock_panel=mock_panel) - editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) - - # Simulate zoom state on the live panel - sel = _make_mock_selection(zoom={'A': [2, 5], 'B': [10, 30]}) - live_panel = type('LivePanel', (), {'selection': sel, 'width': None, 'height': None})() - editor.component._panel = live_panel - - result = editor.export('png') - assert isinstance(result, BytesIO) - assert len(mock_panel.calls) == 1 - - def test_export_without_bounds_unchanged(self, vegalite_editor, mock_panel): - """Verify export works normally when no zoom state exists.""" - result = vegalite_editor.export('png') - assert isinstance(result, BytesIO) - assert len(mock_panel.calls) == 1 - - def test_export_uses_live_panel_dimensions(self, monkeypatch, mock_panel): - """Verify export uses live panel width/height when available.""" - monkeypatch.setattr(editors_module, 'ParamMethod', lambda *a, **kw: None) - monkeypatch.setattr(VegaLiteEditor, '_update_component', lambda self, *a, **kw: None) - - component = MockVegaComponent(_mock_panel=mock_panel) - editor = VegaLiteEditor(component=component, spec=_MINIMAL_VEGALITE_SPEC) - - live_panel = type('LivePanel', (), { - 'selection': None, 'width': 1200, 'height': 600 - })() - editor.component._panel = live_panel - - result = editor.export('png') - assert isinstance(result, BytesIO) - assert len(mock_panel.calls) == 1 + spec_with_dims = """\ +$schema: https://vega.github.io/schema/vega-lite/v5.json +width: 500 +height: 300 +mark: bar +encoding: + x: + field: A + type: quantitative + y: + field: B + type: quantitative +data: + values: + - {A: 1, B: 2} +""" + component = MockVegaComponent(_mock_panel=mock_panel) + editor = VegaLiteEditor(component=component, spec=spec_with_dims) + result = editor.export('png') + assert isinstance(result, BytesIO)