qeviz

qeviz — Python adapter for the qeviz visualization library.

Converts pandas DataFrames to the qeviz ModelData format and provides a chainable plot object for Jupyter display and standalone HTML export.

Quick start

::

import qeviz

# From DataFrames
p = qeviz.plot(nodes_df, edges_df, points_df, group_col="Condition")
p                                       # auto-display in Jupyter
p.group("FirstGame").points().show()    # chain + explicit display

# From a fitted pyENA model
p = qeviz.from_pyena(model, group_col="Condition")
p.edges("FirstGame", also="SecondGame")
p.edges(unit="FirstGame::alice")

# Introspect available groups / units
p.groups()   # ["FirstGame", "SecondGame"]
p.units()    # ["FirstGame::alice", ...]

# Export to file
p.export_html("output.html")

Chain API

All chain methods return a new QEPlot — the original is unchanged.

plot_obj.group(*names, intervals=True) Which group means (and CI boxes) to show. Omit names → all groups.

plot_obj.edges(group, *, unit, compare, also, show) Which edge network to draw. Omit → no network rendered.

plot_obj.points(show=True) Show or hide individual unit scatter dots.

plot_obj.labels(nodes, means, points) Label visibility: "on" | "off" | "click" | "auto".

Dependencies

Required : pandas Optional : scipy (exact t-distribution CIs; falls back to lookup table) IPython (Jupyter inline display) pyENA (for from_pyena())

  1"""
  2qeviz — Python adapter for the qeviz visualization library.
  3
  4Converts pandas DataFrames to the qeviz ModelData format and provides
  5a chainable plot object for Jupyter display and standalone HTML export.
  6
  7Quick start
  8-----------
  9::
 10
 11    import qeviz
 12
 13    # From DataFrames
 14    p = qeviz.plot(nodes_df, edges_df, points_df, group_col="Condition")
 15    p                                       # auto-display in Jupyter
 16    p.group("FirstGame").points().show()    # chain + explicit display
 17
 18    # From a fitted pyENA model
 19    p = qeviz.from_pyena(model, group_col="Condition")
 20    p.edges("FirstGame", also="SecondGame")
 21    p.edges(unit="FirstGame::alice")
 22
 23    # Introspect available groups / units
 24    p.groups()   # ["FirstGame", "SecondGame"]
 25    p.units()    # ["FirstGame::alice", ...]
 26
 27    # Export to file
 28    p.export_html("output.html")
 29
 30Chain API
 31---------
 32All chain methods return a *new* ``QEPlot`` — the original is unchanged.
 33
 34``plot_obj.group(*names, intervals=True)``
 35    Which group means (and CI boxes) to show.  Omit names → all groups.
 36
 37``plot_obj.edges(group, *, unit, compare, also, show)``
 38    Which edge network to draw.  Omit → no network rendered.
 39
 40``plot_obj.points(show=True)``
 41    Show or hide individual unit scatter dots.
 42
 43``plot_obj.labels(nodes, means, points)``
 44    Label visibility: ``"on" | "off" | "click" | "auto"``.
 45
 46Dependencies
 47------------
 48Required : pandas
 49Optional : scipy  (exact t-distribution CIs; falls back to lookup table)
 50           IPython (Jupyter inline display)
 51           pyENA  (for :func:`from_pyena`)
 52"""
 53
 54from __future__ import annotations
 55
 56import copy
 57import json
 58import math
 59import os
 60import time
 61from typing import Optional, Union
 62
 63import pandas as pd
 64
 65
 66# ── Internal helpers ───────────────────────────────────────────────────────────
 67
 68def _qe_frame(df: pd.DataFrame) -> dict:
 69    """Convert a pandas DataFrame to a QEFrame ``{data: [...], types: {...}}``."""
 70    _type_map = {
 71        "object":  "character",
 72        "string":  "character",
 73        "int64":   "integer",
 74        "int32":   "integer",
 75        "int16":   "integer",
 76        "int8":    "integer",
 77        "float64": "numeric",
 78        "float32": "numeric",
 79        "bool":    "logical",
 80    }
 81    types = {
 82        col: _type_map.get(str(df[col].dtype), "character")
 83        for col in df.columns
 84    }
 85    data = []
 86    for _, row in df.iterrows():
 87        row_dict: dict = {}
 88        for col, val in row.items():
 89            if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
 90                row_dict[col] = None
 91            elif hasattr(val, "item"):          # numpy scalar → Python native
 92                row_dict[col] = val.item()
 93            else:
 94                row_dict[col] = val
 95        data.append(row_dict)
 96    return {"data": data, "types": types}
 97
 98
 99def _t_quantile(p: float, df: int) -> float:
100    """Upper t-quantile with scipy if available, lookup-table fallback."""
101    try:
102        from scipy import stats as _stats   # type: ignore
103        return float(_stats.t.ppf(p, df=df))
104    except ImportError:
105        pass
106    lut = {1: 12.706, 2: 4.303, 3: 3.182, 5: 2.571, 10: 2.228,
107           20: 2.086, 30: 2.042, 60: 2.000, 120: 1.980}
108    for threshold in sorted(lut):
109        if df <= threshold:
110            return lut[threshold]
111    return 1.960
112
113
114def _group_summaries(
115    points_df: pd.DataFrame,
116    group_col: str,
117    dim_cols: list,
118    conf_level: float = 0.95,
119    include_ci: bool = True,
120) -> pd.DataFrame:
121    """Per-group means and optional rectangular 95 % CI bounds."""
122    rows = []
123    for g in points_df[group_col].unique():
124        sub = points_df[points_df[group_col] == g][dim_cols].apply(
125            pd.to_numeric, errors="coerce"
126        )
127        n     = len(sub)
128        means = sub.mean()
129        row: dict = {group_col: str(g)}
130        for d in dim_cols:
131            row[d] = float(means[d])
132        if include_ci and n >= 2:
133            t_val = _t_quantile((1 + conf_level) / 2, df=n - 1)
134            for d in dim_cols:
135                se              = float(sub[d].std(ddof=1)) / math.sqrt(n)
136                row[f"{d}.low"]  = row[d] - t_val * se
137                row[f"{d}.high"] = row[d] + t_val * se
138        rows.append(row)
139    return pd.DataFrame(rows)
140
141
142def _bundle_script_tag() -> str:
143    """Return a ``<script>`` tag with the inlined UMD bundle."""
144    bundle_path = os.path.join(
145        os.path.dirname(os.path.abspath(__file__)), "qeviz.umd.js"
146    )
147    if os.path.exists(bundle_path):
148        with open(bundle_path, "r", encoding="utf-8") as fh:
149            return f"<script>{fh.read()}</script>"
150    return '<script src="qeviz.umd.js"></script>'
151
152
153# ── QEPlot ─────────────────────────────────────────────────────────────────────
154
155class QEPlot:
156    """
157    A lazily-rendered qeviz plot.
158
159    Created by :func:`plot` or :func:`from_pyena`.  Chain :meth:`group`,
160    :meth:`edges`, :meth:`points`, and :meth:`labels` to customise the view.
161
162    Display options:
163
164    * Evaluate the object as the last expression in a Jupyter cell — the
165      ``_repr_html_`` hook triggers automatic inline display.
166    * Call :meth:`show` explicitly to display in IPython / Jupyter.
167    * Call :meth:`export_html` to write a self-contained ``.html`` file.
168
169    All chain methods return a *new* ``QEPlot`` so the original is not
170    mutated and multiple views can branch from a common base.
171    """
172
173    def __init__(
174        self,
175        model: dict,
176        title: str = "qeviz",
177        width: Union[int, str] = 700,
178        height: Union[int, str] = 500,
179    ) -> None:
180        self._model  = model
181        self._title  = title
182        # Normalise width/height to CSS strings for the <qe-graph> attribute.
183        self._width  = str(width)  if isinstance(width,  str) else f"{width}px"
184        self._height = str(height) if isinstance(height, str) else f"{height}px"
185        self._opts: dict = {}
186
187    # ── Model access ───────────────────────────────────────────────────────────
188
189    @property
190    def model(self) -> dict:
191        """The raw ModelData dict (nodes, edges, points, groups, …)."""
192        return self._model
193
194    # ── Copy helper ────────────────────────────────────────────────────────────
195
196    def _copy(self) -> "QEPlot":
197        """Return a shallow copy with an independent ``_opts`` dict."""
198        p          = QEPlot.__new__(QEPlot)
199        p._model   = self._model       # shared — model data is read-only
200        p._title   = self._title
201        p._width   = self._width
202        p._height  = self._height
203        p._opts    = dict(self._opts)  # independent copy
204        return p
205
206    # ── Introspection ──────────────────────────────────────────────────────────
207
208    def groups(self) -> list:
209        """Return the group names present in the model (from the groups frame)."""
210        frame = self._model.get("groups")
211        if not frame:
212            return []
213        group_col = self._model.get("group_col") or "group"
214        result = []
215        for row in frame.get("data", []):
216            val = row.get(group_col)
217            if val is None and row:
218                val = next(iter(row.values()))
219            if val is not None:
220                result.append(str(val))
221        return result
222
223    def units(self) -> list:
224        """Return the unit IDs present in the model's points frame."""
225        frame = self._model.get("points")
226        if not frame:
227            return []
228        id_col = self._model.get("id_col") or "QEUNIT"
229        return [
230            str(row[id_col])
231            for row in frame.get("data", [])
232            if id_col in row
233        ]
234
235    # ── Chain methods ──────────────────────────────────────────────────────────
236
237    def group(self, *names: str, intervals: bool = True) -> "QEPlot":
238        """
239        Select which group means (and optionally CI boxes) to display.
240
241        Pass one or more group names to restrict the view; omit all names to
242        show every group in the model (the default).  This is independent of
243        edge rendering — use :meth:`edges` to choose whose network to draw.
244
245        Parameters
246        ----------
247        *names      Group names to display.  Omit to show all groups.
248        intervals   Draw 95 % CI rectangles around each mean.  Default ``True``.
249        """
250        p = self._copy()
251        available = self.groups()
252        if names:
253            bad = [n for n in names if n not in available]
254            if bad:
255                raise ValueError(
256                    f"group(): name(s) not found in model: {bad!r}\n"
257                    f"Available: {available!r}"
258                )
259            p._opts["groups"] = ",".join(names)
260        else:
261            p._opts.pop("groups", None)
262        p._opts["confidence"] = intervals
263        return p
264
265    def edges(
266        self,
267        group: Optional[str] = None,
268        *,
269        unit: Optional[str] = None,
270        compare: Optional[str] = None,
271        also: Optional[str] = None,
272        show: bool = True,
273    ) -> "QEPlot":
274        """
275        Select which edge network to draw.
276
277        Supply either ``group`` (a group's mean network) or ``unit`` (an
278        individual unit's personal network) as the primary edge source.
279        Omit both to draw no network.  Pass ``show=False`` to explicitly
280        suppress any previously set edges.
281
282        Parameters
283        ----------
284        group   Group name whose mean edge network to draw.
285        unit    Unit ID for an individual network, e.g.
286                ``"FirstGame::alice"``.  Mutually exclusive with ``group``.
287        compare Second group to subtract (``group − compare``).
288        also    Second group to overlay alongside ``group``.
289        show    ``False`` suppresses all edges regardless of other args.
290        """
291        if group and unit:
292            raise ValueError("edges(): supply either 'group' or 'unit', not both.")
293
294        p = self._copy()
295        if not show:
296            p._opts["show_edges"] = False
297            for k in ("group", "unit", "compare", "also"):
298                p._opts.pop(k, None)
299            return p
300
301        available_groups = self.groups()
302        available_units  = self.units()
303
304        def _check_group(name: str, arg: str) -> None:
305            if name and name not in available_groups:
306                raise ValueError(
307                    f"edges(): {arg}={name!r} not found.\n"
308                    f"Available groups: {available_groups!r}"
309                )
310
311        _check_group(group,   "group")
312        _check_group(compare, "compare")
313        _check_group(also,    "also")
314
315        if unit and unit not in available_units:
316            raise ValueError(
317                f"edges(): unit={unit!r} not found.\n"
318                "Use .units() to see available unit IDs."
319            )
320        if compare and also:
321            raise ValueError("edges(): supply either 'compare' or 'also', not both.")
322        if unit and (compare or also):
323            raise ValueError(
324                "edges(): 'compare' and 'also' cannot be combined with 'unit'."
325            )
326
327        p._opts["show_edges"] = True
328        p._opts["group"]   = group
329        p._opts["unit"]    = unit
330        p._opts["compare"] = compare
331        p._opts["also"]    = also
332        return p
333
334    def points(self, show: bool = True) -> "QEPlot":
335        """Show or hide individual unit scatter dots."""
336        p = self._copy()
337        p._opts["show_points"] = show
338        return p
339
340    def labels(
341        self,
342        nodes:  str = "on",
343        means:  str = "on",
344        points: str = "auto",
345    ) -> "QEPlot":
346        """
347        Control label visibility.
348
349        Each argument accepts ``"on"``, ``"off"``, ``"click"``, or ``"auto"``.
350        """
351        p = self._copy()
352        p._opts["label_nodes"]  = nodes
353        p._opts["label_means"]  = means
354        p._opts["label_points"] = points
355        return p
356
357    # ── HTML rendering ─────────────────────────────────────────────────────────
358
359    def _build_graph_element(self) -> str:
360        """Build ``<qe-graph …>…</qe-graph>`` with declarative child layer elements."""
361        opts = self._opts
362        children: list[str] = []
363
364        # <qe-nodes> — always present; controls node label mode
365        node_label = opts.get("label_nodes", "on")
366        children.append(f'    <qe-nodes label="{node_label}"></qe-nodes>')
367
368        # <qe-means> — always present; groups/confidence control what is shown
369        means_parts: list[str] = []
370        if opts.get("groups"):
371            means_parts.append(f'groups="{opts["groups"]}"')
372        if opts.get("confidence", True):
373            means_parts.append("confidence")
374        means_label = opts.get("label_means", "on")
375        means_parts.append(f'label="{means_label}"')
376        children.append(f'    <qe-means {" ".join(means_parts)}></qe-means>')
377
378        # <qe-points> — only when show_points=True
379        if opts.get("show_points", False):
380            pt_label = opts.get("label_points", "auto")
381            children.append(f'    <qe-points label="{pt_label}"></qe-points>')
382
383        # <qe-edges> — only when not suppressed and a primary target is set
384        if opts.get("show_edges", True):
385            edge_parts: list[str] = []
386            if opts.get("group"):
387                edge_parts.append(f'group="{opts["group"]}"')
388            if opts.get("unit"):
389                edge_parts.append(f'unit="{opts["unit"]}"')
390            if opts.get("compare"):
391                edge_parts.append(f'compare="{opts["compare"]}"')
392            if opts.get("also"):
393                edge_parts.append(f'also="{opts["also"]}"')
394            if edge_parts:
395                children.append(
396                    f'    <qe-edges {" ".join(edge_parts)}></qe-edges>'
397                )
398
399        children_str = "\n".join(children)
400        return (
401            f'<qe-graph width="{self._width}" height="{self._height}">\n'
402            f'{children_str}\n'
403            f'  </qe-graph>'
404        )
405
406    def _build_fragment(self) -> str:
407        """HTML fragment for Jupyter inline display (no ``<html>``/``<head>``)."""
408        uid        = f"qeviz-{int(time.time() * 1000) % 100_000}"
409        model_json = json.dumps(self._model, default=str)
410        graph_elem = self._build_graph_element()
411        return (
412            f"{_bundle_script_tag()}\n"
413            f'<qe-visual id="{uid}">\n'
414            f"  {graph_elem}\n"
415            f"</qe-visual>\n"
416            f"<script>\n"
417            f"  (function(){{\n"
418            f"    document.getElementById('{uid}').setModelData({model_json});\n"
419            f"  }})();\n"
420            f"</script>"
421        )
422
423    def _build_html_doc(self) -> str:
424        """Complete self-contained HTML document."""
425        model_json = json.dumps(self._model, default=str)
426        graph_elem = self._build_graph_element()
427        bundle     = _bundle_script_tag()
428        return (
429            f"<!DOCTYPE html>\n"
430            f'<html lang="en">\n'
431            f"<head>\n"
432            f'  <meta charset="UTF-8">\n'
433            f'  <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
434            f"  <title>{self._title}</title>\n"
435            f"  <style>\n"
436            f"    * {{ box-sizing: border-box; margin: 0; padding: 0; }}\n"
437            f"    body {{ font-family: system-ui, sans-serif; background: #fff; }}\n"
438            f"  </style>\n"
439            f"  {bundle}\n"
440            f"</head>\n"
441            f"<body>\n"
442            f'  <qe-visual id="vis">\n'
443            f"    {graph_elem}\n"
444            f"  </qe-visual>\n"
445            f"  <script>\n"
446            f"    (function(){{\n"
447            f"      document.getElementById('vis').setModelData({model_json});\n"
448            f"    }})();\n"
449            f"  </script>\n"
450            f"</body>\n"
451            f"</html>"
452        )
453
454    # ── Display ────────────────────────────────────────────────────────────────
455
456    def show(self) -> None:
457        """
458        Display the plot inline in a Jupyter / IPython notebook.
459
460        In non-interactive environments (plain Python scripts) this falls back
461        to printing the HTML document to stdout.
462        """
463        try:
464            from IPython.display import HTML, display  # type: ignore
465            display(HTML(self._build_fragment()))
466        except ImportError:
467            print(self._build_html_doc())
468
469    def _repr_html_(self) -> str:
470        """Jupyter rich-display hook — called automatically when a cell returns a QEPlot."""
471        return self._build_fragment()
472
473    def export_html(self, path: str) -> str:
474        """
475        Write a self-contained HTML file.
476
477        The qeviz bundle is inlined — no server or external dependencies
478        required.
479
480        Parameters
481        ----------
482        path    Output file path, e.g. ``"model.html"``.
483
484        Returns
485        -------
486        str  Resolved absolute path of the written file.
487        """
488        abs_path = os.path.abspath(path)
489        with open(abs_path, "w", encoding="utf-8") as fh:
490            fh.write(self._build_html_doc())
491        return abs_path
492
493    def __repr__(self) -> str:
494        groups = self.groups()
495        n_units = len(self.units())
496        opts = {k: v for k, v in self._opts.items() if v is not None}
497        return (
498            f"<QEPlot groups={groups!r} units={n_units} opts={opts!r}>"
499        )
500
501
502# ── Public API ─────────────────────────────────────────────────────────────────
503
504def model_data(
505    nodes:   pd.DataFrame,
506    edges:   pd.DataFrame,
507    points:  Optional[pd.DataFrame] = None,
508    *,
509    id_col:       str = "QEUNIT",
510    node_id_col:  str = "code",
511    x_col:        str = "SVD1",
512    y_col:        str = "SVD2",
513    group_col:    Optional[str] = None,
514    directed:     bool = False,
515    conf_level:   float = 0.95,
516    include_ci:   bool = True,
517) -> dict:
518    """
519    Build a qeviz ModelData dict from pandas DataFrames.
520
521    This is the low-level constructor — most users will call :func:`plot`
522    directly.  Use ``model_data()`` when you need to inspect or transform the
523    serialised dict before rendering.
524
525    Parameters
526    ----------
527    nodes         One row per code node.  Must contain ``node_id_col`` plus
528                  ``x_col`` and ``y_col`` position columns.
529    edges         One row per unit.  First column is the unit identifier
530                  (``id_col``); remaining columns are dot-separated edge
531                  weights (``"A.B"``).
532    points        Unit projection positions.  Must contain ``id_col``,
533                  ``x_col``, ``y_col``, and (optionally) ``group_col``.
534    id_col        Unit-identifier column in ``edges`` / ``points``.
535    node_id_col   Node-identifier column in ``nodes``.
536    x_col         X-position column name.
537    y_col         Y-position column name.
538    group_col     Grouping column in ``points``.  When provided, group means
539                  and CI bounds are added as the ``groups`` frame.
540    directed      ``True`` for ONA / directed networks (arrowheads).
541    conf_level    Confidence level for CI boxes.  Default 0.95.
542    include_ci    Include CI bounds in the groups frame.  Default ``True``.
543
544    Returns
545    -------
546    dict  ModelData dict suitable for :func:`plot` or :func:`export_html`.
547    """
548    dim_cols = [x_col, y_col]
549
550    result: dict = {
551        "nodes":       _qe_frame(nodes),
552        "edges":       _qe_frame(edges),
553        "updated":     int(time.time() * 1000),
554        "directed":    directed,
555        "id_col":      id_col,
556        "node_id_col": node_id_col,
557        "x_col":       x_col,
558        "y_col":       y_col,
559        "group_col":   group_col,
560    }
561
562    if points is not None:
563        result["points"] = _qe_frame(points)
564        if group_col and group_col in points.columns:
565            groups_df = _group_summaries(
566                points, group_col, dim_cols,
567                conf_level=conf_level,
568                include_ci=include_ci,
569            )
570            result["groups"] = _qe_frame(groups_df)
571
572    return result
573
574
575def plot(
576    x: Union[pd.DataFrame, dict],
577    edges:  Optional[pd.DataFrame] = None,
578    points: Optional[pd.DataFrame] = None,
579    *,
580    title:     str = "qeviz",
581    width:     Union[int, str] = 700,
582    height:    Union[int, str] = 500,
583    group_col: Optional[str] = None,
584    **model_kwargs,
585) -> QEPlot:
586    """
587    Create a :class:`QEPlot`.
588
589    Accepts either a pre-built ModelData dict **or** raw DataFrames
590    (``nodes``, ``edges``, ``points``).
591
592    Parameters
593    ----------
594    x           A ModelData dict from :func:`model_data`, **or** a nodes
595                DataFrame (in which case ``edges`` must also be supplied).
596    edges       Edge-weights DataFrame (only when ``x`` is a nodes DataFrame).
597    points      Unit-positions DataFrame (optional, only with DataFrames).
598    title       Window / tab title for exported files.
599    width, height
600                Plot dimensions.  Integers are treated as pixels; strings
601                are passed through as CSS (e.g. ``"100%"``).
602    group_col   Grouping column (only when building from DataFrames).
603    **model_kwargs
604                Additional keyword arguments forwarded to :func:`model_data`
605                (e.g. ``id_col``, ``x_col``, ``directed``).
606
607    Returns
608    -------
609    QEPlot
610    """
611    if isinstance(x, dict):
612        m = x
613    else:
614        if edges is None:
615            raise TypeError("plot(): 'edges' is required when 'x' is a DataFrame.")
616        m = model_data(
617            x, edges, points,
618            group_col=group_col,
619            **model_kwargs,
620        )
621    return QEPlot(m, title=title, width=width, height=height)
622
623
624def from_pyena(
625    model,
626    group_col: Optional[str] = None,
627    directed:  bool = False,
628    id_col:    str = "QEUNIT",
629    title:     str = "qeviz",
630    width:     Union[int, str] = 700,
631    height:    Union[int, str] = 500,
632) -> QEPlot:
633    """
634    Convert a fitted pyENA model to a :class:`QEPlot`.
635
636    Python equivalent of R's ``qe_extract()`` — reads nodes, unit positions,
637    edge weights, and group metadata directly from a fitted ``pyena.ENA``
638    instance.
639
640    Parameters
641    ----------
642    model     A fitted ``pyena.ENA`` instance (after ``.fit()``).
643    group_col Grouping column name in ``model.accum_.meta``
644              (e.g. ``"Condition"``).
645    directed  ``True`` for ONA / directed networks.
646    id_col    Name to give the unit-identifier column.
647    title, width, height
648              Passed to the returned :class:`QEPlot`.
649
650    Returns
651    -------
652    QEPlot
653
654    Examples
655    --------
656    ::
657
658        p = qeviz.from_pyena(model, group_col="Condition")
659        p.edges("FirstGame", also="SecondGame").points()  # auto-displays in Jupyter
660        p.export_html("output.html")
661    """
662    # Nodes: code name + (x, y)
663    nodes_df = pd.DataFrame({
664        "code": model.codes_,
665        "x":    model.positions_[:, 0],
666        "y":    model.positions_[:, 1],
667    })
668
669    # Edges: normalised connection weights, "&" → "." separator
670    conn_cols = [c.replace("&", ".") for c in model.connection_names_]
671    edges_df  = pd.DataFrame(model.normed_networks_, columns=conn_cols)
672    edges_df.insert(0, id_col, model.units_)
673
674    # Points: projected (x, y) per unit
675    points_df = pd.DataFrame({
676        id_col: model.units_,
677        "x":    model.points_[:, 0],
678        "y":    model.points_[:, 1],
679    })
680
681    # Attach group from unit-level metadata
682    if group_col is not None:
683        meta = model.accum_.meta
684        if group_col not in meta.columns:
685            raise ValueError(
686                f"group_col {group_col!r} not found in model metadata. "
687                f"Available columns: {list(meta.columns)}"
688            )
689        group_vals = meta.reindex(model.units_)[group_col].tolist()
690        points_df[group_col] = group_vals
691        edges_df[group_col]  = group_vals
692
693    m = model_data(
694        nodes       = nodes_df,
695        edges       = edges_df,
696        points      = points_df,
697        id_col      = id_col,
698        node_id_col = "code",
699        x_col       = "x",
700        y_col       = "y",
701        group_col   = group_col,
702        directed    = directed,
703    )
704    return QEPlot(m, title=title, width=width, height=height)
705
706
707def export_html(
708    model_or_plot: Union[dict, QEPlot],
709    path: str,
710    options: Optional[dict] = None,
711    width:  Union[int, str] = 700,
712    height: Union[int, str] = 500,
713) -> str:
714    """
715    Write a self-contained HTML file.
716
717    Accepts either a :class:`QEPlot` **or** a raw ModelData dict (legacy).
718    When a dict is supplied ``options`` / ``width`` / ``height`` are forwarded
719    to construct a temporary :class:`QEPlot`.
720
721    Returns the resolved absolute path of the written file.
722    """
723    if isinstance(model_or_plot, QEPlot):
724        return model_or_plot.export_html(path)
725
726    # Legacy: raw dict path
727    p = QEPlot(model_or_plot, width=width, height=height)
728    if options:
729        # Map old flat options dict onto chain methods for backward compat.
730        group   = options.get("group")
731        unit    = options.get("unit")
732        compare = options.get("compare")
733        also    = options.get("also")
734        if group or unit or compare or also:
735            p = p.edges(group=group, unit=unit, compare=compare, also=also)
736        if options.get("show_points"):
737            p = p.points(True)
738    return p.export_html(path)
739
740
741__all__ = [
742    "QEPlot",
743    "model_data",
744    "plot",
745    "from_pyena",
746    "export_html",
747]
class QEPlot:
156class QEPlot:
157    """
158    A lazily-rendered qeviz plot.
159
160    Created by :func:`plot` or :func:`from_pyena`.  Chain :meth:`group`,
161    :meth:`edges`, :meth:`points`, and :meth:`labels` to customise the view.
162
163    Display options:
164
165    * Evaluate the object as the last expression in a Jupyter cell — the
166      ``_repr_html_`` hook triggers automatic inline display.
167    * Call :meth:`show` explicitly to display in IPython / Jupyter.
168    * Call :meth:`export_html` to write a self-contained ``.html`` file.
169
170    All chain methods return a *new* ``QEPlot`` so the original is not
171    mutated and multiple views can branch from a common base.
172    """
173
174    def __init__(
175        self,
176        model: dict,
177        title: str = "qeviz",
178        width: Union[int, str] = 700,
179        height: Union[int, str] = 500,
180    ) -> None:
181        self._model  = model
182        self._title  = title
183        # Normalise width/height to CSS strings for the <qe-graph> attribute.
184        self._width  = str(width)  if isinstance(width,  str) else f"{width}px"
185        self._height = str(height) if isinstance(height, str) else f"{height}px"
186        self._opts: dict = {}
187
188    # ── Model access ───────────────────────────────────────────────────────────
189
190    @property
191    def model(self) -> dict:
192        """The raw ModelData dict (nodes, edges, points, groups, …)."""
193        return self._model
194
195    # ── Copy helper ────────────────────────────────────────────────────────────
196
197    def _copy(self) -> "QEPlot":
198        """Return a shallow copy with an independent ``_opts`` dict."""
199        p          = QEPlot.__new__(QEPlot)
200        p._model   = self._model       # shared — model data is read-only
201        p._title   = self._title
202        p._width   = self._width
203        p._height  = self._height
204        p._opts    = dict(self._opts)  # independent copy
205        return p
206
207    # ── Introspection ──────────────────────────────────────────────────────────
208
209    def groups(self) -> list:
210        """Return the group names present in the model (from the groups frame)."""
211        frame = self._model.get("groups")
212        if not frame:
213            return []
214        group_col = self._model.get("group_col") or "group"
215        result = []
216        for row in frame.get("data", []):
217            val = row.get(group_col)
218            if val is None and row:
219                val = next(iter(row.values()))
220            if val is not None:
221                result.append(str(val))
222        return result
223
224    def units(self) -> list:
225        """Return the unit IDs present in the model's points frame."""
226        frame = self._model.get("points")
227        if not frame:
228            return []
229        id_col = self._model.get("id_col") or "QEUNIT"
230        return [
231            str(row[id_col])
232            for row in frame.get("data", [])
233            if id_col in row
234        ]
235
236    # ── Chain methods ──────────────────────────────────────────────────────────
237
238    def group(self, *names: str, intervals: bool = True) -> "QEPlot":
239        """
240        Select which group means (and optionally CI boxes) to display.
241
242        Pass one or more group names to restrict the view; omit all names to
243        show every group in the model (the default).  This is independent of
244        edge rendering — use :meth:`edges` to choose whose network to draw.
245
246        Parameters
247        ----------
248        *names      Group names to display.  Omit to show all groups.
249        intervals   Draw 95 % CI rectangles around each mean.  Default ``True``.
250        """
251        p = self._copy()
252        available = self.groups()
253        if names:
254            bad = [n for n in names if n not in available]
255            if bad:
256                raise ValueError(
257                    f"group(): name(s) not found in model: {bad!r}\n"
258                    f"Available: {available!r}"
259                )
260            p._opts["groups"] = ",".join(names)
261        else:
262            p._opts.pop("groups", None)
263        p._opts["confidence"] = intervals
264        return p
265
266    def edges(
267        self,
268        group: Optional[str] = None,
269        *,
270        unit: Optional[str] = None,
271        compare: Optional[str] = None,
272        also: Optional[str] = None,
273        show: bool = True,
274    ) -> "QEPlot":
275        """
276        Select which edge network to draw.
277
278        Supply either ``group`` (a group's mean network) or ``unit`` (an
279        individual unit's personal network) as the primary edge source.
280        Omit both to draw no network.  Pass ``show=False`` to explicitly
281        suppress any previously set edges.
282
283        Parameters
284        ----------
285        group   Group name whose mean edge network to draw.
286        unit    Unit ID for an individual network, e.g.
287                ``"FirstGame::alice"``.  Mutually exclusive with ``group``.
288        compare Second group to subtract (``group − compare``).
289        also    Second group to overlay alongside ``group``.
290        show    ``False`` suppresses all edges regardless of other args.
291        """
292        if group and unit:
293            raise ValueError("edges(): supply either 'group' or 'unit', not both.")
294
295        p = self._copy()
296        if not show:
297            p._opts["show_edges"] = False
298            for k in ("group", "unit", "compare", "also"):
299                p._opts.pop(k, None)
300            return p
301
302        available_groups = self.groups()
303        available_units  = self.units()
304
305        def _check_group(name: str, arg: str) -> None:
306            if name and name not in available_groups:
307                raise ValueError(
308                    f"edges(): {arg}={name!r} not found.\n"
309                    f"Available groups: {available_groups!r}"
310                )
311
312        _check_group(group,   "group")
313        _check_group(compare, "compare")
314        _check_group(also,    "also")
315
316        if unit and unit not in available_units:
317            raise ValueError(
318                f"edges(): unit={unit!r} not found.\n"
319                "Use .units() to see available unit IDs."
320            )
321        if compare and also:
322            raise ValueError("edges(): supply either 'compare' or 'also', not both.")
323        if unit and (compare or also):
324            raise ValueError(
325                "edges(): 'compare' and 'also' cannot be combined with 'unit'."
326            )
327
328        p._opts["show_edges"] = True
329        p._opts["group"]   = group
330        p._opts["unit"]    = unit
331        p._opts["compare"] = compare
332        p._opts["also"]    = also
333        return p
334
335    def points(self, show: bool = True) -> "QEPlot":
336        """Show or hide individual unit scatter dots."""
337        p = self._copy()
338        p._opts["show_points"] = show
339        return p
340
341    def labels(
342        self,
343        nodes:  str = "on",
344        means:  str = "on",
345        points: str = "auto",
346    ) -> "QEPlot":
347        """
348        Control label visibility.
349
350        Each argument accepts ``"on"``, ``"off"``, ``"click"``, or ``"auto"``.
351        """
352        p = self._copy()
353        p._opts["label_nodes"]  = nodes
354        p._opts["label_means"]  = means
355        p._opts["label_points"] = points
356        return p
357
358    # ── HTML rendering ─────────────────────────────────────────────────────────
359
360    def _build_graph_element(self) -> str:
361        """Build ``<qe-graph …>…</qe-graph>`` with declarative child layer elements."""
362        opts = self._opts
363        children: list[str] = []
364
365        # <qe-nodes> — always present; controls node label mode
366        node_label = opts.get("label_nodes", "on")
367        children.append(f'    <qe-nodes label="{node_label}"></qe-nodes>')
368
369        # <qe-means> — always present; groups/confidence control what is shown
370        means_parts: list[str] = []
371        if opts.get("groups"):
372            means_parts.append(f'groups="{opts["groups"]}"')
373        if opts.get("confidence", True):
374            means_parts.append("confidence")
375        means_label = opts.get("label_means", "on")
376        means_parts.append(f'label="{means_label}"')
377        children.append(f'    <qe-means {" ".join(means_parts)}></qe-means>')
378
379        # <qe-points> — only when show_points=True
380        if opts.get("show_points", False):
381            pt_label = opts.get("label_points", "auto")
382            children.append(f'    <qe-points label="{pt_label}"></qe-points>')
383
384        # <qe-edges> — only when not suppressed and a primary target is set
385        if opts.get("show_edges", True):
386            edge_parts: list[str] = []
387            if opts.get("group"):
388                edge_parts.append(f'group="{opts["group"]}"')
389            if opts.get("unit"):
390                edge_parts.append(f'unit="{opts["unit"]}"')
391            if opts.get("compare"):
392                edge_parts.append(f'compare="{opts["compare"]}"')
393            if opts.get("also"):
394                edge_parts.append(f'also="{opts["also"]}"')
395            if edge_parts:
396                children.append(
397                    f'    <qe-edges {" ".join(edge_parts)}></qe-edges>'
398                )
399
400        children_str = "\n".join(children)
401        return (
402            f'<qe-graph width="{self._width}" height="{self._height}">\n'
403            f'{children_str}\n'
404            f'  </qe-graph>'
405        )
406
407    def _build_fragment(self) -> str:
408        """HTML fragment for Jupyter inline display (no ``<html>``/``<head>``)."""
409        uid        = f"qeviz-{int(time.time() * 1000) % 100_000}"
410        model_json = json.dumps(self._model, default=str)
411        graph_elem = self._build_graph_element()
412        return (
413            f"{_bundle_script_tag()}\n"
414            f'<qe-visual id="{uid}">\n'
415            f"  {graph_elem}\n"
416            f"</qe-visual>\n"
417            f"<script>\n"
418            f"  (function(){{\n"
419            f"    document.getElementById('{uid}').setModelData({model_json});\n"
420            f"  }})();\n"
421            f"</script>"
422        )
423
424    def _build_html_doc(self) -> str:
425        """Complete self-contained HTML document."""
426        model_json = json.dumps(self._model, default=str)
427        graph_elem = self._build_graph_element()
428        bundle     = _bundle_script_tag()
429        return (
430            f"<!DOCTYPE html>\n"
431            f'<html lang="en">\n'
432            f"<head>\n"
433            f'  <meta charset="UTF-8">\n'
434            f'  <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
435            f"  <title>{self._title}</title>\n"
436            f"  <style>\n"
437            f"    * {{ box-sizing: border-box; margin: 0; padding: 0; }}\n"
438            f"    body {{ font-family: system-ui, sans-serif; background: #fff; }}\n"
439            f"  </style>\n"
440            f"  {bundle}\n"
441            f"</head>\n"
442            f"<body>\n"
443            f'  <qe-visual id="vis">\n'
444            f"    {graph_elem}\n"
445            f"  </qe-visual>\n"
446            f"  <script>\n"
447            f"    (function(){{\n"
448            f"      document.getElementById('vis').setModelData({model_json});\n"
449            f"    }})();\n"
450            f"  </script>\n"
451            f"</body>\n"
452            f"</html>"
453        )
454
455    # ── Display ────────────────────────────────────────────────────────────────
456
457    def show(self) -> None:
458        """
459        Display the plot inline in a Jupyter / IPython notebook.
460
461        In non-interactive environments (plain Python scripts) this falls back
462        to printing the HTML document to stdout.
463        """
464        try:
465            from IPython.display import HTML, display  # type: ignore
466            display(HTML(self._build_fragment()))
467        except ImportError:
468            print(self._build_html_doc())
469
470    def _repr_html_(self) -> str:
471        """Jupyter rich-display hook — called automatically when a cell returns a QEPlot."""
472        return self._build_fragment()
473
474    def export_html(self, path: str) -> str:
475        """
476        Write a self-contained HTML file.
477
478        The qeviz bundle is inlined — no server or external dependencies
479        required.
480
481        Parameters
482        ----------
483        path    Output file path, e.g. ``"model.html"``.
484
485        Returns
486        -------
487        str  Resolved absolute path of the written file.
488        """
489        abs_path = os.path.abspath(path)
490        with open(abs_path, "w", encoding="utf-8") as fh:
491            fh.write(self._build_html_doc())
492        return abs_path
493
494    def __repr__(self) -> str:
495        groups = self.groups()
496        n_units = len(self.units())
497        opts = {k: v for k, v in self._opts.items() if v is not None}
498        return (
499            f"<QEPlot groups={groups!r} units={n_units} opts={opts!r}>"
500        )

A lazily-rendered qeviz plot.

Created by plot() or from_pyena(). Chain group(), edges(), points(), and labels() to customise the view.

Display options:

  • Evaluate the object as the last expression in a Jupyter cell — the _repr_html_ hook triggers automatic inline display.
  • Call show() explicitly to display in IPython / Jupyter.
  • Call export_html() to write a self-contained .html file.

All chain methods return a new QEPlot so the original is not mutated and multiple views can branch from a common base.

QEPlot( model: dict, title: str = 'qeviz', width: Union[int, str] = 700, height: Union[int, str] = 500)
174    def __init__(
175        self,
176        model: dict,
177        title: str = "qeviz",
178        width: Union[int, str] = 700,
179        height: Union[int, str] = 500,
180    ) -> None:
181        self._model  = model
182        self._title  = title
183        # Normalise width/height to CSS strings for the <qe-graph> attribute.
184        self._width  = str(width)  if isinstance(width,  str) else f"{width}px"
185        self._height = str(height) if isinstance(height, str) else f"{height}px"
186        self._opts: dict = {}
model: dict
190    @property
191    def model(self) -> dict:
192        """The raw ModelData dict (nodes, edges, points, groups, …)."""
193        return self._model

The raw ModelData dict (nodes, edges, points, groups, …).

def groups(self) -> list:
209    def groups(self) -> list:
210        """Return the group names present in the model (from the groups frame)."""
211        frame = self._model.get("groups")
212        if not frame:
213            return []
214        group_col = self._model.get("group_col") or "group"
215        result = []
216        for row in frame.get("data", []):
217            val = row.get(group_col)
218            if val is None and row:
219                val = next(iter(row.values()))
220            if val is not None:
221                result.append(str(val))
222        return result

Return the group names present in the model (from the groups frame).

def units(self) -> list:
224    def units(self) -> list:
225        """Return the unit IDs present in the model's points frame."""
226        frame = self._model.get("points")
227        if not frame:
228            return []
229        id_col = self._model.get("id_col") or "QEUNIT"
230        return [
231            str(row[id_col])
232            for row in frame.get("data", [])
233            if id_col in row
234        ]

Return the unit IDs present in the model's points frame.

def group(self, *names: str, intervals: bool = True) -> QEPlot:
238    def group(self, *names: str, intervals: bool = True) -> "QEPlot":
239        """
240        Select which group means (and optionally CI boxes) to display.
241
242        Pass one or more group names to restrict the view; omit all names to
243        show every group in the model (the default).  This is independent of
244        edge rendering — use :meth:`edges` to choose whose network to draw.
245
246        Parameters
247        ----------
248        *names      Group names to display.  Omit to show all groups.
249        intervals   Draw 95 % CI rectangles around each mean.  Default ``True``.
250        """
251        p = self._copy()
252        available = self.groups()
253        if names:
254            bad = [n for n in names if n not in available]
255            if bad:
256                raise ValueError(
257                    f"group(): name(s) not found in model: {bad!r}\n"
258                    f"Available: {available!r}"
259                )
260            p._opts["groups"] = ",".join(names)
261        else:
262            p._opts.pop("groups", None)
263        p._opts["confidence"] = intervals
264        return p

Select which group means (and optionally CI boxes) to display.

Pass one or more group names to restrict the view; omit all names to show every group in the model (the default). This is independent of edge rendering — use edges() to choose whose network to draw.

Parameters

*names Group names to display. Omit to show all groups. intervals Draw 95 % CI rectangles around each mean. Default True.

def edges( self, group: Optional[str] = None, *, unit: Optional[str] = None, compare: Optional[str] = None, also: Optional[str] = None, show: bool = True) -> QEPlot:
266    def edges(
267        self,
268        group: Optional[str] = None,
269        *,
270        unit: Optional[str] = None,
271        compare: Optional[str] = None,
272        also: Optional[str] = None,
273        show: bool = True,
274    ) -> "QEPlot":
275        """
276        Select which edge network to draw.
277
278        Supply either ``group`` (a group's mean network) or ``unit`` (an
279        individual unit's personal network) as the primary edge source.
280        Omit both to draw no network.  Pass ``show=False`` to explicitly
281        suppress any previously set edges.
282
283        Parameters
284        ----------
285        group   Group name whose mean edge network to draw.
286        unit    Unit ID for an individual network, e.g.
287                ``"FirstGame::alice"``.  Mutually exclusive with ``group``.
288        compare Second group to subtract (``group − compare``).
289        also    Second group to overlay alongside ``group``.
290        show    ``False`` suppresses all edges regardless of other args.
291        """
292        if group and unit:
293            raise ValueError("edges(): supply either 'group' or 'unit', not both.")
294
295        p = self._copy()
296        if not show:
297            p._opts["show_edges"] = False
298            for k in ("group", "unit", "compare", "also"):
299                p._opts.pop(k, None)
300            return p
301
302        available_groups = self.groups()
303        available_units  = self.units()
304
305        def _check_group(name: str, arg: str) -> None:
306            if name and name not in available_groups:
307                raise ValueError(
308                    f"edges(): {arg}={name!r} not found.\n"
309                    f"Available groups: {available_groups!r}"
310                )
311
312        _check_group(group,   "group")
313        _check_group(compare, "compare")
314        _check_group(also,    "also")
315
316        if unit and unit not in available_units:
317            raise ValueError(
318                f"edges(): unit={unit!r} not found.\n"
319                "Use .units() to see available unit IDs."
320            )
321        if compare and also:
322            raise ValueError("edges(): supply either 'compare' or 'also', not both.")
323        if unit and (compare or also):
324            raise ValueError(
325                "edges(): 'compare' and 'also' cannot be combined with 'unit'."
326            )
327
328        p._opts["show_edges"] = True
329        p._opts["group"]   = group
330        p._opts["unit"]    = unit
331        p._opts["compare"] = compare
332        p._opts["also"]    = also
333        return p

Select which edge network to draw.

Supply either group (a group's mean network) or unit (an individual unit's personal network) as the primary edge source. Omit both to draw no network. Pass show=False to explicitly suppress any previously set edges.

Parameters

group Group name whose mean edge network to draw. unit Unit ID for an individual network, e.g. "FirstGame::alice". Mutually exclusive with group. compare Second group to subtract (group − compare). also Second group to overlay alongside group. show False suppresses all edges regardless of other args.

def points(self, show: bool = True) -> QEPlot:
335    def points(self, show: bool = True) -> "QEPlot":
336        """Show or hide individual unit scatter dots."""
337        p = self._copy()
338        p._opts["show_points"] = show
339        return p

Show or hide individual unit scatter dots.

def labels( self, nodes: str = 'on', means: str = 'on', points: str = 'auto') -> QEPlot:
341    def labels(
342        self,
343        nodes:  str = "on",
344        means:  str = "on",
345        points: str = "auto",
346    ) -> "QEPlot":
347        """
348        Control label visibility.
349
350        Each argument accepts ``"on"``, ``"off"``, ``"click"``, or ``"auto"``.
351        """
352        p = self._copy()
353        p._opts["label_nodes"]  = nodes
354        p._opts["label_means"]  = means
355        p._opts["label_points"] = points
356        return p

Control label visibility.

Each argument accepts "on", "off", "click", or "auto".

def show(self) -> None:
457    def show(self) -> None:
458        """
459        Display the plot inline in a Jupyter / IPython notebook.
460
461        In non-interactive environments (plain Python scripts) this falls back
462        to printing the HTML document to stdout.
463        """
464        try:
465            from IPython.display import HTML, display  # type: ignore
466            display(HTML(self._build_fragment()))
467        except ImportError:
468            print(self._build_html_doc())

Display the plot inline in a Jupyter / IPython notebook.

In non-interactive environments (plain Python scripts) this falls back to printing the HTML document to stdout.

def export_html(self, path: str) -> str:
474    def export_html(self, path: str) -> str:
475        """
476        Write a self-contained HTML file.
477
478        The qeviz bundle is inlined — no server or external dependencies
479        required.
480
481        Parameters
482        ----------
483        path    Output file path, e.g. ``"model.html"``.
484
485        Returns
486        -------
487        str  Resolved absolute path of the written file.
488        """
489        abs_path = os.path.abspath(path)
490        with open(abs_path, "w", encoding="utf-8") as fh:
491            fh.write(self._build_html_doc())
492        return abs_path

Write a self-contained HTML file.

The qeviz bundle is inlined — no server or external dependencies required.

Parameters

path Output file path, e.g. "model.html".

Returns

str Resolved absolute path of the written file.

def model_data( nodes: pandas.DataFrame, edges: pandas.DataFrame, points: Optional[pandas.DataFrame] = None, *, id_col: str = 'QEUNIT', node_id_col: str = 'code', x_col: str = 'SVD1', y_col: str = 'SVD2', group_col: Optional[str] = None, directed: bool = False, conf_level: float = 0.95, include_ci: bool = True) -> dict:
505def model_data(
506    nodes:   pd.DataFrame,
507    edges:   pd.DataFrame,
508    points:  Optional[pd.DataFrame] = None,
509    *,
510    id_col:       str = "QEUNIT",
511    node_id_col:  str = "code",
512    x_col:        str = "SVD1",
513    y_col:        str = "SVD2",
514    group_col:    Optional[str] = None,
515    directed:     bool = False,
516    conf_level:   float = 0.95,
517    include_ci:   bool = True,
518) -> dict:
519    """
520    Build a qeviz ModelData dict from pandas DataFrames.
521
522    This is the low-level constructor — most users will call :func:`plot`
523    directly.  Use ``model_data()`` when you need to inspect or transform the
524    serialised dict before rendering.
525
526    Parameters
527    ----------
528    nodes         One row per code node.  Must contain ``node_id_col`` plus
529                  ``x_col`` and ``y_col`` position columns.
530    edges         One row per unit.  First column is the unit identifier
531                  (``id_col``); remaining columns are dot-separated edge
532                  weights (``"A.B"``).
533    points        Unit projection positions.  Must contain ``id_col``,
534                  ``x_col``, ``y_col``, and (optionally) ``group_col``.
535    id_col        Unit-identifier column in ``edges`` / ``points``.
536    node_id_col   Node-identifier column in ``nodes``.
537    x_col         X-position column name.
538    y_col         Y-position column name.
539    group_col     Grouping column in ``points``.  When provided, group means
540                  and CI bounds are added as the ``groups`` frame.
541    directed      ``True`` for ONA / directed networks (arrowheads).
542    conf_level    Confidence level for CI boxes.  Default 0.95.
543    include_ci    Include CI bounds in the groups frame.  Default ``True``.
544
545    Returns
546    -------
547    dict  ModelData dict suitable for :func:`plot` or :func:`export_html`.
548    """
549    dim_cols = [x_col, y_col]
550
551    result: dict = {
552        "nodes":       _qe_frame(nodes),
553        "edges":       _qe_frame(edges),
554        "updated":     int(time.time() * 1000),
555        "directed":    directed,
556        "id_col":      id_col,
557        "node_id_col": node_id_col,
558        "x_col":       x_col,
559        "y_col":       y_col,
560        "group_col":   group_col,
561    }
562
563    if points is not None:
564        result["points"] = _qe_frame(points)
565        if group_col and group_col in points.columns:
566            groups_df = _group_summaries(
567                points, group_col, dim_cols,
568                conf_level=conf_level,
569                include_ci=include_ci,
570            )
571            result["groups"] = _qe_frame(groups_df)
572
573    return result

Build a qeviz ModelData dict from pandas DataFrames.

This is the low-level constructor — most users will call plot() directly. Use model_data() when you need to inspect or transform the serialised dict before rendering.

Parameters

nodes One row per code node. Must contain node_id_col plus x_col and y_col position columns. edges One row per unit. First column is the unit identifier (id_col); remaining columns are dot-separated edge weights ("A.B"). points Unit projection positions. Must contain id_col, x_col, y_col, and (optionally) group_col. id_col Unit-identifier column in edges / points. node_id_col Node-identifier column in nodes. x_col X-position column name. y_col Y-position column name. group_col Grouping column in points. When provided, group means and CI bounds are added as the groups frame. directed True for ONA / directed networks (arrowheads). conf_level Confidence level for CI boxes. Default 0.95. include_ci Include CI bounds in the groups frame. Default True.

Returns

dict ModelData dict suitable for plot() or export_html().

def plot( x: Union[pandas.DataFrame, dict], edges: Optional[pandas.DataFrame] = None, points: Optional[pandas.DataFrame] = None, *, title: str = 'qeviz', width: Union[int, str] = 700, height: Union[int, str] = 500, group_col: Optional[str] = None, **model_kwargs) -> QEPlot:
576def plot(
577    x: Union[pd.DataFrame, dict],
578    edges:  Optional[pd.DataFrame] = None,
579    points: Optional[pd.DataFrame] = None,
580    *,
581    title:     str = "qeviz",
582    width:     Union[int, str] = 700,
583    height:    Union[int, str] = 500,
584    group_col: Optional[str] = None,
585    **model_kwargs,
586) -> QEPlot:
587    """
588    Create a :class:`QEPlot`.
589
590    Accepts either a pre-built ModelData dict **or** raw DataFrames
591    (``nodes``, ``edges``, ``points``).
592
593    Parameters
594    ----------
595    x           A ModelData dict from :func:`model_data`, **or** a nodes
596                DataFrame (in which case ``edges`` must also be supplied).
597    edges       Edge-weights DataFrame (only when ``x`` is a nodes DataFrame).
598    points      Unit-positions DataFrame (optional, only with DataFrames).
599    title       Window / tab title for exported files.
600    width, height
601                Plot dimensions.  Integers are treated as pixels; strings
602                are passed through as CSS (e.g. ``"100%"``).
603    group_col   Grouping column (only when building from DataFrames).
604    **model_kwargs
605                Additional keyword arguments forwarded to :func:`model_data`
606                (e.g. ``id_col``, ``x_col``, ``directed``).
607
608    Returns
609    -------
610    QEPlot
611    """
612    if isinstance(x, dict):
613        m = x
614    else:
615        if edges is None:
616            raise TypeError("plot(): 'edges' is required when 'x' is a DataFrame.")
617        m = model_data(
618            x, edges, points,
619            group_col=group_col,
620            **model_kwargs,
621        )
622    return QEPlot(m, title=title, width=width, height=height)

Create a QEPlot.

Accepts either a pre-built ModelData dict or raw DataFrames (nodes, edges, points).

Parameters

x A ModelData dict from model_data(), or a nodes DataFrame (in which case edges must also be supplied). edges Edge-weights DataFrame (only when x is a nodes DataFrame). points Unit-positions DataFrame (optional, only with DataFrames). title Window / tab title for exported files. width, height Plot dimensions. Integers are treated as pixels; strings are passed through as CSS (e.g. "100%"). group_col Grouping column (only when building from DataFrames). **model_kwargs Additional keyword arguments forwarded to model_data() (e.g. id_col, x_col, directed).

Returns

QEPlot

def from_pyena( model, group_col: Optional[str] = None, directed: bool = False, id_col: str = 'QEUNIT', title: str = 'qeviz', width: Union[int, str] = 700, height: Union[int, str] = 500) -> QEPlot:
625def from_pyena(
626    model,
627    group_col: Optional[str] = None,
628    directed:  bool = False,
629    id_col:    str = "QEUNIT",
630    title:     str = "qeviz",
631    width:     Union[int, str] = 700,
632    height:    Union[int, str] = 500,
633) -> QEPlot:
634    """
635    Convert a fitted pyENA model to a :class:`QEPlot`.
636
637    Python equivalent of R's ``qe_extract()`` — reads nodes, unit positions,
638    edge weights, and group metadata directly from a fitted ``pyena.ENA``
639    instance.
640
641    Parameters
642    ----------
643    model     A fitted ``pyena.ENA`` instance (after ``.fit()``).
644    group_col Grouping column name in ``model.accum_.meta``
645              (e.g. ``"Condition"``).
646    directed  ``True`` for ONA / directed networks.
647    id_col    Name to give the unit-identifier column.
648    title, width, height
649              Passed to the returned :class:`QEPlot`.
650
651    Returns
652    -------
653    QEPlot
654
655    Examples
656    --------
657    ::
658
659        p = qeviz.from_pyena(model, group_col="Condition")
660        p.edges("FirstGame", also="SecondGame").points()  # auto-displays in Jupyter
661        p.export_html("output.html")
662    """
663    # Nodes: code name + (x, y)
664    nodes_df = pd.DataFrame({
665        "code": model.codes_,
666        "x":    model.positions_[:, 0],
667        "y":    model.positions_[:, 1],
668    })
669
670    # Edges: normalised connection weights, "&" → "." separator
671    conn_cols = [c.replace("&", ".") for c in model.connection_names_]
672    edges_df  = pd.DataFrame(model.normed_networks_, columns=conn_cols)
673    edges_df.insert(0, id_col, model.units_)
674
675    # Points: projected (x, y) per unit
676    points_df = pd.DataFrame({
677        id_col: model.units_,
678        "x":    model.points_[:, 0],
679        "y":    model.points_[:, 1],
680    })
681
682    # Attach group from unit-level metadata
683    if group_col is not None:
684        meta = model.accum_.meta
685        if group_col not in meta.columns:
686            raise ValueError(
687                f"group_col {group_col!r} not found in model metadata. "
688                f"Available columns: {list(meta.columns)}"
689            )
690        group_vals = meta.reindex(model.units_)[group_col].tolist()
691        points_df[group_col] = group_vals
692        edges_df[group_col]  = group_vals
693
694    m = model_data(
695        nodes       = nodes_df,
696        edges       = edges_df,
697        points      = points_df,
698        id_col      = id_col,
699        node_id_col = "code",
700        x_col       = "x",
701        y_col       = "y",
702        group_col   = group_col,
703        directed    = directed,
704    )
705    return QEPlot(m, title=title, width=width, height=height)

Convert a fitted pyENA model to a QEPlot.

Python equivalent of R's qe_extract() — reads nodes, unit positions, edge weights, and group metadata directly from a fitted pyena.ENA instance.

Parameters

model A fitted pyena.ENA instance (after .fit()). group_col Grouping column name in model.accum_.meta (e.g. "Condition"). directed True for ONA / directed networks. id_col Name to give the unit-identifier column. title, width, height Passed to the returned QEPlot.

Returns

QEPlot

Examples

::

p = qeviz.from_pyena(model, group_col="Condition")
p.edges("FirstGame", also="SecondGame").points()  # auto-displays in Jupyter
p.export_html("output.html")
def export_html( model_or_plot: Union[dict, QEPlot], path: str, options: Optional[dict] = None, width: Union[int, str] = 700, height: Union[int, str] = 500) -> str:
708def export_html(
709    model_or_plot: Union[dict, QEPlot],
710    path: str,
711    options: Optional[dict] = None,
712    width:  Union[int, str] = 700,
713    height: Union[int, str] = 500,
714) -> str:
715    """
716    Write a self-contained HTML file.
717
718    Accepts either a :class:`QEPlot` **or** a raw ModelData dict (legacy).
719    When a dict is supplied ``options`` / ``width`` / ``height`` are forwarded
720    to construct a temporary :class:`QEPlot`.
721
722    Returns the resolved absolute path of the written file.
723    """
724    if isinstance(model_or_plot, QEPlot):
725        return model_or_plot.export_html(path)
726
727    # Legacy: raw dict path
728    p = QEPlot(model_or_plot, width=width, height=height)
729    if options:
730        # Map old flat options dict onto chain methods for backward compat.
731        group   = options.get("group")
732        unit    = options.get("unit")
733        compare = options.get("compare")
734        also    = options.get("also")
735        if group or unit or compare or also:
736            p = p.edges(group=group, unit=unit, compare=compare, also=also)
737        if options.get("show_points"):
738            p = p.points(True)
739    return p.export_html(path)

Write a self-contained HTML file.

Accepts either a QEPlot or a raw ModelData dict (legacy). When a dict is supplied options / width / height are forwarded to construct a temporary QEPlot.

Returns the resolved absolute path of the written file.