Skip to content

Commit 6fef4eb

Browse files
Add headless web API for CoPaper.ai integration
- render_to_base64(dpi, fmt): headless figure rendering to base64 - get_editable_schema(): export editable properties, roles, and values for dynamic frontend panel generation - apply_actions(actions): action-based batch editing via JSON commands (set_title, set_color, set_grid, apply_font_preset, etc.) - export_state() / import_state(): full editor state serialization for persistence across sessions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ae9d928 commit 6fef4eb

1 file changed

Lines changed: 257 additions & 0 deletions

File tree

src/statspai/plots/interactive.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,263 @@ def apply_edits_list(self, edits: list):
14711471
self.edits.append(edit)
14721472
self._refresh()
14731473

1474+
# ------------------------------------------------------------------
1475+
# Headless Web API — for CoPaper.ai and other web backends
1476+
# ------------------------------------------------------------------
1477+
1478+
def render_to_base64(self, dpi: int = 300, fmt: str = 'png') -> str:
1479+
"""Render the figure to a base64-encoded string.
1480+
1481+
Parameters
1482+
----------
1483+
dpi : int, default 300
1484+
Resolution for the rendered image.
1485+
fmt : str, default 'png'
1486+
Image format ('png', 'svg', 'pdf').
1487+
1488+
Returns
1489+
-------
1490+
str
1491+
Base64-encoded image data.
1492+
"""
1493+
import base64
1494+
import io
1495+
buf = io.BytesIO()
1496+
self.fig.savefig(buf, format=fmt, dpi=dpi,
1497+
bbox_inches='tight',
1498+
facecolor=self.fig.get_facecolor())
1499+
buf.seek(0)
1500+
return base64.b64encode(buf.read()).decode('ascii')
1501+
1502+
def get_editable_schema(self) -> dict:
1503+
"""Export schema of all editable properties and current values.
1504+
1505+
Returns a dict that a web frontend can use to dynamically
1506+
generate an editing panel. Each element includes its current
1507+
value, role, and whether it's editable.
1508+
1509+
Returns
1510+
-------
1511+
dict
1512+
Schema with axes, figure-level settings, font presets,
1513+
and theme options.
1514+
"""
1515+
from .themes import list_themes
1516+
1517+
axes_schema = []
1518+
for i, ax in enumerate(self.fig.get_axes()):
1519+
# Lines
1520+
lines_schema = []
1521+
for j, line in enumerate(ax.get_lines()):
1522+
role = self.artist_roles.get(id(line), ArtistRole.COSMETIC)
1523+
editable = role not in (
1524+
ArtistRole.DATA, ArtistRole.FIT, ArtistRole.CI
1525+
) if self.protect_data else True
1526+
lines_schema.append({
1527+
'index': j,
1528+
'label': line.get_label() or f'line{j}',
1529+
'role': role.name,
1530+
'editable': editable,
1531+
'color': line.get_color(),
1532+
'linewidth': line.get_linewidth(),
1533+
'linestyle': line.get_linestyle(),
1534+
'alpha': line.get_alpha(),
1535+
})
1536+
1537+
# Collections (scatter, fill_between, etc.)
1538+
collections_schema = []
1539+
for j, coll in enumerate(ax.collections):
1540+
role = self.artist_roles.get(id(coll), ArtistRole.COSMETIC)
1541+
editable = role not in (
1542+
ArtistRole.DATA, ArtistRole.FIT, ArtistRole.CI
1543+
) if self.protect_data else True
1544+
collections_schema.append({
1545+
'index': j,
1546+
'role': role.name,
1547+
'editable': editable,
1548+
'alpha': coll.get_alpha(),
1549+
})
1550+
1551+
# Spines
1552+
spines = {}
1553+
for name, spine in ax.spines.items():
1554+
spines[name] = spine.get_visible()
1555+
1556+
# Grid
1557+
gridlines = ax.xaxis.get_gridlines()
1558+
grid_on = gridlines[0].get_visible() if gridlines else False
1559+
1560+
# Legend
1561+
legend = ax.get_legend()
1562+
legend_info = None
1563+
if legend is not None:
1564+
legend_info = {
1565+
'visible': legend.get_visible(),
1566+
'loc': legend._loc if hasattr(legend, '_loc') else 'best',
1567+
}
1568+
1569+
axes_schema.append({
1570+
'index': i,
1571+
'title': {
1572+
'text': ax.get_title(),
1573+
'fontsize': ax.title.get_fontsize(),
1574+
'color': ax.title.get_color(),
1575+
},
1576+
'xlabel': {
1577+
'text': ax.get_xlabel(),
1578+
'fontsize': ax.xaxis.label.get_fontsize(),
1579+
},
1580+
'ylabel': {
1581+
'text': ax.get_ylabel(),
1582+
'fontsize': ax.yaxis.label.get_fontsize(),
1583+
},
1584+
'xlim': list(ax.get_xlim()),
1585+
'ylim': list(ax.get_ylim()),
1586+
'lines': lines_schema,
1587+
'collections': collections_schema,
1588+
'spines': spines,
1589+
'grid': grid_on,
1590+
'legend': legend_info,
1591+
})
1592+
1593+
# Figure-level
1594+
w, h = self.fig.get_size_inches()
1595+
1596+
return {
1597+
'axes': axes_schema,
1598+
'figure': {
1599+
'width': float(w),
1600+
'height': float(h),
1601+
'dpi': int(self.fig.dpi),
1602+
'facecolor': self.fig.get_facecolor(),
1603+
},
1604+
'font_presets': list(FONT_PRESETS.keys()),
1605+
'themes': list_themes(),
1606+
'protect_data': self.protect_data,
1607+
}
1608+
1609+
def apply_actions(self, actions: list):
1610+
"""Apply a list of action-based edit commands.
1611+
1612+
This is the primary API for web frontends. Each action is a
1613+
dict with an ``action`` key specifying the method to call.
1614+
1615+
Parameters
1616+
----------
1617+
actions : list of dict
1618+
Each dict has ``action`` plus method-specific params.
1619+
Supported actions::
1620+
1621+
{"action": "set_title", "text": "...", "ax_index": 0}
1622+
{"action": "set_xlabel", "text": "...", "ax_index": 0}
1623+
{"action": "set_ylabel", "text": "...", "ax_index": 0}
1624+
{"action": "set_fontsize", "target": "title", "size": 12, "ax_index": 0}
1625+
{"action": "set_color", "target": "title", "color": "#333", "ax_index": 0}
1626+
{"action": "set_dpi", "dpi": 300}
1627+
{"action": "set_figsize", "width": 8, "height": 6}
1628+
{"action": "set_grid", "visible": true, "ax_index": 0}
1629+
{"action": "set_spine_visible", "spine": "top", "visible": false, "ax_index": 0}
1630+
{"action": "apply_font_preset", "preset": "CJK Journal", "ax_index": 0}
1631+
{"action": "apply_theme", "theme": "clean"}
1632+
{"action": "set_legend_visible", "visible": true, "ax_index": 0}
1633+
{"action": "set_line_color", "line_index": 0, "color": "#E74C3C", "ax_index": 0}
1634+
{"action": "set_line_alpha", "line_index": 0, "alpha": 0.8, "ax_index": 0}
1635+
1636+
Raises
1637+
------
1638+
ValueError
1639+
If an unknown action is encountered.
1640+
"""
1641+
_dispatch = {
1642+
'set_title': lambda a: self.set_title(
1643+
a['text'], ax_index=a.get('ax_index', 0)),
1644+
'set_xlabel': lambda a: self.set_xlabel(
1645+
a['text'], ax_index=a.get('ax_index', 0)),
1646+
'set_ylabel': lambda a: self.set_ylabel(
1647+
a['text'], ax_index=a.get('ax_index', 0)),
1648+
'set_fontsize': lambda a: self.set_fontsize(
1649+
a['target'], a['size'], ax_index=a.get('ax_index', 0)),
1650+
'set_color': lambda a: self.set_color(
1651+
a['target'], a['color'], ax_index=a.get('ax_index', 0)),
1652+
'set_dpi': lambda a: self.set_dpi(a['dpi']),
1653+
'set_figsize': lambda a: self.set_figsize(
1654+
a['width'], a['height']),
1655+
'set_grid': lambda a: self.set_grid(
1656+
a['visible'], ax_index=a.get('ax_index', 0)),
1657+
'set_spine_visible': lambda a: self.set_spine_visible(
1658+
a['spine'], a['visible'], ax_index=a.get('ax_index', 0)),
1659+
'apply_font_preset': lambda a: self.apply_font_preset(
1660+
a['preset'], ax_index=a.get('ax_index', 0)),
1661+
'apply_theme': lambda a: self.apply_theme(a['theme']),
1662+
'set_legend_visible': lambda a: self.set_legend_visible(
1663+
a['visible'], ax_index=a.get('ax_index', 0)),
1664+
'set_line_color': lambda a: self.set_color(
1665+
f"line{a['line_index']}", a['color'],
1666+
ax_index=a.get('ax_index', 0)),
1667+
'set_line_alpha': lambda a: self.set_alpha(
1668+
f"line{a['line_index']}", a['alpha'],
1669+
ax_index=a.get('ax_index', 0)),
1670+
'set_linewidth': lambda a: self.set_linewidth(
1671+
a['line_index'], a['width'],
1672+
ax_index=a.get('ax_index', 0)),
1673+
'set_linestyle': lambda a: self.set_linestyle(
1674+
a['line_index'], a['style'],
1675+
ax_index=a.get('ax_index', 0)),
1676+
}
1677+
1678+
for action in actions:
1679+
name = action.get('action')
1680+
handler = _dispatch.get(name)
1681+
if handler is None:
1682+
raise ValueError(
1683+
f"Unknown action: {name!r}. "
1684+
f"Supported: {sorted(_dispatch.keys())}")
1685+
handler(action)
1686+
1687+
def export_state(self) -> dict:
1688+
"""Export the full editor state for persistence.
1689+
1690+
Returns a dict containing edits and figure metadata,
1691+
suitable for storing in a database or sending over the wire.
1692+
Pair with ``import_state()`` to restore edits on a fresh figure.
1693+
1694+
Returns
1695+
-------
1696+
dict
1697+
Serialized state with edits, figure settings, and metadata.
1698+
"""
1699+
w, h = self.fig.get_size_inches()
1700+
return {
1701+
'version': 1,
1702+
'edits': self.to_edits_list(),
1703+
'figure': {
1704+
'width': float(w),
1705+
'height': float(h),
1706+
'dpi': int(self.fig.dpi),
1707+
},
1708+
'code': self.generate_code(include_comment=False),
1709+
}
1710+
1711+
def import_state(self, state: dict):
1712+
"""Restore editor state from a previously exported dict.
1713+
1714+
Parameters
1715+
----------
1716+
state : dict
1717+
State dict from ``export_state()``.
1718+
"""
1719+
# Apply figure-level settings first
1720+
fig_state = state.get('figure', {})
1721+
if 'width' in fig_state and 'height' in fig_state:
1722+
self.set_figsize(fig_state['width'], fig_state['height'])
1723+
if 'dpi' in fig_state:
1724+
self.set_dpi(fig_state['dpi'])
1725+
1726+
# Replay edits
1727+
edits = state.get('edits', [])
1728+
if edits:
1729+
self.apply_edits_list(edits)
1730+
14741731
def summary(self) -> str:
14751732
"""Return a summary of all edits."""
14761733
if not self.edits:

0 commit comments

Comments
 (0)