from __future__ import annotations
from typing import Any
"""
GlyphX utility functions: SVG helpers, display detection, legend rendering.
"""
import html
import os
import math
import tempfile
import webbrowser
from pathlib import Path
# ---------------------------------------------------------------------------
# Numeric helpers
# ---------------------------------------------------------------------------
[docs]
def normalize(data):
"""
Normalize a numeric array to the [0, 1] range.
Args:
data (array-like): List or NumPy array of values.
Returns:
np.ndarray: Values scaled to [0, 1].
Raises:
ValueError: If all values are equal (zero-width range).
"""
import numpy as np
arr = np.array(data, dtype=float)
lo, hi = arr.min(), arr.max()
if hi == lo:
raise ValueError("normalize() requires data with non-zero range; all values are equal.")
return (arr - lo) / (hi - lo)
def _format_tick(val, is_log: bool = False):
"""
Format a numeric tick label intelligently.
On log axes uses clean power-of-ten notation (1, 10, 100, 1k, 1M).
On linear axes uses readable decimal notation.
Args:
val (float): Tick value.
is_log (bool): Whether the axis is logarithmic.
Returns:
str: Human-readable label.
"""
if val == 0:
return "0"
abs_val = abs(val)
if is_log:
# Clean log-scale labels: prefer 1/10/100/1k/1M/1B notation
if abs_val >= 1e9:
v = val / 1e9
return f"{int(v)}B" if v == int(v) else f"{v:.1f}B"
if abs_val >= 1e6:
v = val / 1e6
return f"{int(v)}M" if v == int(v) else f"{v:.1f}M"
if abs_val >= 1e3:
v = val / 1e3
return f"{int(v)}k" if v == int(v) else f"{v:.1f}k"
if val == int(val):
return str(int(val))
return f"{val:.2g}"
# Linear axis
if abs_val >= 1e9:
v = val / 1e9
return f"{int(v)}B" if v == int(v) else f"{v:.1f}B"
if abs_val >= 1e6:
v = val / 1e6
return f"{int(v)}M" if v == int(v) else f"{v:.1f}M"
if abs_val >= 1e3 and val == int(val):
return f"{int(val):,}"
if abs_val < 1e-3 and abs_val > 0:
return f"{val:.2e}"
if val == int(val):
return str(int(val))
if abs_val >= 100:
return f"{val:.0f}"
if abs_val >= 10:
return f"{val:.1f}"
return f"{val:.2f}"
# ---------------------------------------------------------------------------
# SVG escaping
# ---------------------------------------------------------------------------
[docs]
def svg_escape(text):
"""
Escape a string for safe embedding inside SVG text or attribute values.
Args:
text (str): Raw user-provided string.
Returns:
str: HTML-escaped string safe for SVG.
"""
return html.escape(str(text), quote=True)
# ---------------------------------------------------------------------------
# SVG/HTML wrapping
# ---------------------------------------------------------------------------
def wrap_svg_with_template(svg_string: str) -> str:
"""
Wrap raw <svg> content in a responsive HTML template with interactivity.
Includes:
- Mouse-hover tooltip support
- Export buttons (SVG, PNG)
- Zoom/pan via mouse wheel + drag
- Click-to-toggle legend
Args:
svg_string (str): Raw SVG markup string.
Returns:
str: Full HTML document with embedded SVG and JS.
Raises:
FileNotFoundError: If the HTML template asset is missing.
"""
template_path = Path(__file__).parent / "assets" / "responsive_template.html"
zoom_path = Path(__file__).parent / "assets" / "zoom.js"
if not template_path.exists():
raise FileNotFoundError(
f"Missing responsive_template.html in assets folder: {template_path}"
)
html_content = template_path.read_text(encoding="utf-8")
zoom_script = ""
if zoom_path.exists():
zoom_content = zoom_path.read_text(encoding="utf-8")
zoom_script = f"<script>\n{zoom_content}\n</script>"
legend_js = """
<script>
document.querySelectorAll('.legend-icon, .legend-label').forEach(el => {
el.addEventListener('click', () => {
const target = el.dataset.target;
const elems = document.querySelectorAll('.' + target);
elems.forEach(e => {
e.style.display = e.style.display === 'none' ? '' : 'none';
});
});
});
</script>
"""
# MathJax -- inject only when the SVG contains $...$ math text
mathjax_script = ""
if 'data-has-math="true"' in svg_string:
mathjax_script = (
'<script>MathJax={tex:{inlineMath:[["$","$"]]}}</script>\n'
'<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>\n'
)
brush_script = ""
brush_path = Path(__file__).parent / "assets" / "brush.js"
if brush_path.exists():
brush_content = brush_path.read_text(encoding="utf-8")
brush_script = f"<script>\n{brush_content}\n</script>"
interact_script = ""
interact_path = Path(__file__).parent / "assets" / "interact.js"
if interact_path.exists():
interact_content = interact_path.read_text(encoding="utf-8")
interact_script = f"<script>\n{interact_content}\n</script>"
a11y_path = Path(__file__).parent / "assets" / "accessibility.js"
a11y_script = ""
if a11y_path.exists():
a11y_content = a11y_path.read_text(encoding="utf-8")
a11y_script = f"<script>\n{a11y_content}\n</script>"
return (
html_content
.replace("{{svg_content}}", svg_string)
.replace("{{extra_scripts}}", mathjax_script + zoom_script + brush_script + interact_script + a11y_script + legend_js)
)
[docs]
def wrap_svg_canvas(svg_content: str, width: int = 640, height: int = 480,
has_math: bool = False) -> str:
"""
Wrap raw SVG elements in a full <svg> root element.
Each SVG gets a collision-resistant UUID id (no module-level counter
that grows unboundedly in long-running Jupyter sessions).
Args:
svg_content (str): Inner SVG markup.
width (int): Canvas width in pixels.
height (int): Canvas height in pixels.
has_math (bool): When True, embeds a MathJax data attribute so
wrap_svg_with_template injects the CDN script.
Returns:
str: Complete SVG document string.
"""
import uuid
chart_id = f"glyphx-chart-{uuid.uuid4().hex[:12]}"
math_attr = ' data-has-math="true"' if has_math else ""
return (
f'<svg id="{chart_id}" data-glyphx="true"{math_attr} '
f'width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {width} {height}">{svg_content}</svg>'
)
def wrap_svg_with_css_vars(svg_string: str, light_theme: dict, dark_theme: dict,
width: int = 640, height: int = 480) -> str:
"""
Wrap an SVG in a ``<style>`` block that defines CSS custom properties
for every theme colour, then swaps them automatically via
``@media (prefers-color-scheme: dark)``.
The SVG itself uses ``var(--glyphx-bg)``, ``var(--glyphx-text)`` etc.
so no Python re-render is needed when the user switches dark mode.
Args:
svg_string: Raw SVG inner content (without the ``<svg>`` root).
light_theme: GlyphX theme dict for light mode.
dark_theme: GlyphX theme dict for dark mode.
width, height: Canvas dimensions.
Returns:
Complete ``<svg>`` element with an embedded ``<style>`` block.
"""
import uuid as _uuid
chart_id = f"glyphx-css-{_uuid.uuid4().hex[:10]}"
def _props(theme: dict) -> str:
mapping = {
"--glyphx-bg": theme.get("background", "#ffffff"),
"--glyphx-text": theme.get("text_color", "#000000"),
"--glyphx-grid": theme.get("grid_color", "#dddddd"),
"--glyphx-axis": theme.get("axis_color", "#333333"),
"--glyphx-accent": theme.get("colors", ["#1f77b4"])[0],
}
return "; ".join(f"{k}: {v}" for k, v in mapping.items())
light_props = _props(light_theme)
dark_props = _props(dark_theme)
style = (
f"<style>"
f"#{chart_id} {{ {light_props} }} "
f"@media (prefers-color-scheme: dark) {{ #{chart_id} {{ {dark_props} }} }} "
f"#{chart_id} {{ background: var(--glyphx-bg); }}"
f"</style>"
)
return (
f'<svg id="{chart_id}" data-glyphx="true" data-responsive="true" '
f'width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {width} {height}">'
+ style
+ svg_string
+ "</svg>"
)
[docs]
def write_svg_file(svg_string: str, filename: str, **kwargs):
"""
Save a chart to file. Supports .svg, .html, .png, and .jpg.
PNG/JPG export requires the optional ``cairosvg`` package::
pip install cairosvg
Args:
svg_string (str): Raw SVG content.
filename (str): Output path. Extension determines format.
Raises:
ValueError: For unsupported extensions.
RuntimeError: If cairosvg is not installed when exporting raster images.
"""
ext = os.path.splitext(filename)[-1].lower()
if ext == ".html":
content = wrap_svg_with_template(svg_string)
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
elif ext == ".svg":
with open(filename, "w", encoding="utf-8") as f:
f.write(svg_string)
elif ext in {".png", ".jpg", ".jpeg"}:
try:
import cairosvg
except ImportError:
raise RuntimeError(
"PNG/JPG export requires cairosvg. Install it with:\n"
" pip install cairosvg"
)
# dpi may be passed as a keyword via write_svg_file(... dpi=192)
_dpi = kwargs.get("dpi", 96)
_scale = _dpi / 96.0 # cairosvg scale=2 doubles resolution
cairosvg.svg2png(bytestring=svg_string.encode(),
write_to=filename, scale=_scale)
else:
raise ValueError(
f"Unsupported file extension '{ext}'. "
"Use .svg, .html, .png, or .jpg."
)
# ---------------------------------------------------------------------------
# Environment detection
# ---------------------------------------------------------------------------
def in_jupyter() -> bool:
"""Return True if executing inside a Jupyter kernel."""
try:
from IPython import get_ipython
return "IPKernelApp" in get_ipython().config
except Exception:
return False
def in_cli_or_ide() -> bool:
"""Return True if NOT inside a Jupyter kernel."""
return not in_jupyter()
def render_cli(svg_string: str):
"""
Write an SVG to a temporary HTML file and open it in the system browser.
Uses NamedTemporaryFile to avoid the race condition in the deprecated
``tempfile.mktemp``.
Args:
svg_string (str): Raw SVG markup to embed.
"""
with tempfile.NamedTemporaryFile(
delete=False, suffix=".html", mode="w", encoding="utf-8"
) as f:
f.write(f"<html><body>{svg_string}</body></html>")
path = f.name
webbrowser.open(f"file://{path}")
# ---------------------------------------------------------------------------
# Legend rendering
# ---------------------------------------------------------------------------
# Fixed gutter width reserved for outside-right legends.
# Must be wide enough for typical labels; figure.py uses this to shrink axes.
LEGEND_GUTTER = 130
def legend_pixel_width(series_list, padding=10, icon_size=12, text_gap=8):
"""Return the pixel width a legend block would occupy for the given series."""
normalized = [
(item[0] if isinstance(item, tuple) else item)
for item in series_list
if getattr(item[0] if isinstance(item, tuple) else item, "label", None)
]
if not normalized:
return 0
max_label_len = max(len(s.label) for s in normalized)
return icon_size + text_gap + max_label_len * 7 + 2 * padding
def draw_legend(
series_list,
position="top-right",
font="sans-serif",
text_color="#000",
fig_width=640,
fig_height=480,
cell_width=None,
cell_height=None,
):
"""
Render a dynamic SVG legend block for a list of series.
Only series with a non-empty ``.label`` attribute are included.
Args:
series_list (list): Series objects or ``(series, use_y2)`` tuples.
position (str): One of top-right, top-left, bottom-right, bottom-left,
top, bottom, left, right.
font (str): CSS font-family string.
text_color (str): SVG fill color for label text.
fig_width (int): Figure canvas width (used for positioning).
fig_height (int): Figure canvas height.
cell_width (int | None): Subplot cell width (overrides fig_width).
cell_height (int | None): Subplot cell height (overrides fig_height).
Returns:
str: SVG ``<g>`` element containing the legend, or empty string if
no labelled series exist.
"""
# Unwrap (series, use_y2) tuples and keep only labelled series
normalized = []
for item in series_list:
s = item[0] if isinstance(item, tuple) else item
if getattr(s, "label", None):
normalized.append(s)
if not normalized:
return ""
spacing = 22
padding = 10
icon_size = 12
text_gap = 8
width = cell_width if cell_width else fig_width
height = cell_height if cell_height else fig_height
# Estimate legend box dimensions using per-character width lookup.
# Average proportional-font character width ~= 7px at font-size 12.
max_label_len = max(len(s.label) for s in normalized)
label_px_width = max_label_len * 7
legend_width = icon_size + text_gap + label_px_width + 2 * padding
legend_height = len(normalized) * spacing + 2 * padding
# Determine top-left corner of the legend box
x = y = padding
if position in ("outside-right", "right-of"):
# Legend sits in the right margin (gutter) inside the full canvas.
# figure.py shrinks the axes to LEGEND_GUTTER pixels narrower,
# so the legend at x = width + gap never overlaps chart data.
x = width + 8
y = max(8, (height - legend_height) // 2)
elif position == "top-right":
x = width - legend_width - padding
elif position == "bottom-right":
x = width - legend_width - padding
y = height - legend_height - padding
elif position == "bottom-left":
y = height - legend_height - padding
elif position == "top":
x = (width - legend_width) // 2
elif position == "bottom":
x = (width - legend_width) // 2
y = height - legend_height - padding
elif position == "left":
y = (height - legend_height) // 2
elif position == "right":
x = width - legend_width - padding
y = (height - legend_height) // 2
# default / "top-left" -> x=padding, y=padding (already set)
items = []
for i, s in enumerate(normalized):
class_name = getattr(s, "css_class", f"series-{i}")
color = getattr(s, "color", "#888") or "#888"
label = svg_escape(s.label)
cy = y + padding + i * spacing
items.append(
f'<rect x="{x}" y="{cy}" width="{icon_size}" height="{icon_size}" '
f'fill="{color}" class="legend-icon" data-target="{class_name}" />'
)
items.append(
f'<text x="{x + icon_size + text_gap}" y="{cy + icon_size - 2}" '
f'font-size="12" font-family="{font}" fill="{text_color}" '
f'class="legend-label" data-target="{class_name}">{label}</text>'
)
return '<g class="glyphx-legend">\n' + "\n".join(items) + "\n</g>"
# ---------------------------------------------------------------------------
# Arc geometry (for pie charts)
# ---------------------------------------------------------------------------
def describe_arc(cx, cy, r, start_angle, end_angle):
"""
Build an SVG arc path string for a pie/donut slice.
Args:
cx (float): Circle center X.
cy (float): Circle center Y.
r (float): Radius.
start_angle (float): Start angle in degrees.
end_angle (float): End angle in degrees.
Returns:
str: SVG ``d`` attribute value for a filled arc slice.
"""
start_rad = math.radians(start_angle)
end_rad = math.radians(end_angle)
x_start = cx + r * math.cos(start_rad)
y_start = cy + r * math.sin(start_rad)
x_end = cx + r * math.cos(end_rad)
y_end = cy + r * math.sin(end_rad)
large_arc = 1 if (end_angle - start_angle) > 180 else 0
return (
f"M {cx},{cy} "
f"L {x_start},{y_start} "
f"A {r},{r} 0 {large_arc},1 {x_end},{y_end} "
"Z"
)
# ---------------------------------------------------------------------------
# Self-contained / shareable HTML
# ---------------------------------------------------------------------------
[docs]
def make_shareable_html(svg_string: str, title: str = "GlyphX Chart") -> str:
"""
Build a fully self-contained HTML document with all JavaScript inlined.
The output has zero external dependencies and renders correctly in:
- Email clients (tested in Gmail, Outlook web)
- Confluence / Notion embeds
- GitHub Pages / static hosts
- Air-gapped / offline environments
Args:
svg_string (str): Raw SVG markup.
title (str): ``<title>`` tag value.
Returns:
str: Complete, standalone HTML document string.
"""
import datetime
assets_dir = Path(__file__).parent / "assets"
def _read_js(name: str) -> str:
p = assets_dir / name
return p.read_text(encoding="utf-8") if p.exists() else ""
tooltip_js = _read_js("tooltip.js") # legacy path -- already in template
zoom_js = _read_js("zoom.js")
brush_js = _read_js("brush.js")
interact_js = _read_js("interact.js")
export_js = _read_js("export.js")
# Read template and replace placeholders
template_path = assets_dir / "responsive_template.html"
if not template_path.exists():
raise FileNotFoundError(f"Missing template: {template_path}")
html = template_path.read_text(encoding="utf-8")
# Replace title
html = html.replace("<title>GlyphX Chart</title>", f"<title>{html_escape(title)}</title>")
# Inline all JS into {{extra_scripts}}
a11y_js = _read_js("accessibility.js")
inlined_scripts = "\n".join(filter(None, [
f"<script>\n{zoom_js}\n</script>" if zoom_js else "",
f"<script>\n{brush_js}\n</script>" if brush_js else "",
f"<script>\n{interact_js}\n</script>" if interact_js else "",
f"<script>\n{a11y_js}\n</script>" if a11y_js else "",
f"<script>\n{export_js}\n</script>" if export_js else "",
]))
# Metadata comment
meta = (
f"<!-- GlyphX self-contained export\n"
f" Generated : {datetime.datetime.utcnow().isoformat(timespec='seconds')}Z\n"
f" Zero external dependencies -- share freely\n-->\n"
)
html = (
html
.replace("{{svg_content}}", svg_string)
.replace("{{extra_scripts}}", inlined_scripts)
)
return meta + html
def html_escape(text: str) -> str:
"""Alias for ``html.escape`` for use within this module."""
import html as _html
return _html.escape(str(text))