"""Interactive SVG export with tooltip support.
Post-processes the SVG produced by :class:`~mappyng.map.Map` to inject
hover tooltips via embedded JavaScript. No external dependency,
the result is a self-contained SVG (Jupyter-friendly) or HTML file.
Example
-------
>>> m = Map(gdf, bbox=bbox, facecolor="#B9D9EB")
>>> m.add(BasemapLayer())
>>> m.add(ChoroplethLayer(gdf_dep, column="population"))
>>> interactive = m.to_interactive()
>>> interactive.save("carte.html")
>>>
>>> # Customize tooltips
>>> m.to_interactive(
... columns=["NOM", "POP"],
... aliases={"NOM": "Nom", "POP": "Population"},
... tooltip={"background": "#1a1a2e", "font_size": 13},
... )
"""
import json
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from xml.etree import ElementTree as ET
import numpy as np
# SVG element tags that can carry tooltips
_VISUAL_TAGS = frozenset({"path", "circle", "rect", "ellipse", "polygon",
"polyline", "line", "use"})
# Columns to exclude from auto-detected tooltips
_INTERNAL_COLUMNS = frozenset({
"geometry", "_class", "_color", "_sort_size",
})
# ============================================================================
# Tooltip style
# ============================================================================
[docs]
@dataclass
class HoverEffect:
"""Hover visual effect configuration for interactive SVG elements.
Effects are implemented via CSS transitions + SVG filters so they
animate smoothly. Set ``enabled=False`` to disable all hover
visuals (tooltip still works).
Attributes
----------
enabled : bool
Master switch for hover effects.
transition : str
CSS transition timing (applies to all animated properties).
opacity : float
Opacity on hover (< 1 = dim, > 1 ignored).
scale : float
Scale factor on hover (1.0 = no change).
shadow : bool
Show a drop-shadow on hover.
shadow_color : str
Shadow color.
shadow_dx : float
Shadow horizontal offset (SVG px).
shadow_dy : float
Shadow vertical offset (SVG px).
shadow_blur : float
Shadow blur radius (SVG px).
stroke_width_boost : float
Extra stroke-width on hover (added to existing, 0 = no change).
stroke_color : str or None
Override stroke color on hover (None = keep original).
"""
enabled: bool = True
transition: str = "all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94)"
opacity: float = 1.0
scale: float = 1.0
shadow: bool = False
shadow_color: str = "rgba(0,0,0,0.35)"
shadow_dx: float = 0.0
shadow_dy: float = 4.0
shadow_blur: float = 8.0
stroke_width_boost: float = 1.0
stroke_color: Optional[str] = "#333333"
def _ensure_hover_effect(hover: Any) -> HoverEffect:
"""Normalize a hover input to :class:`HoverEffect`."""
if hover is None or hover is True:
return HoverEffect()
if hover is False:
return HoverEffect(enabled=False)
if isinstance(hover, dict):
return HoverEffect(**hover)
if isinstance(hover, HoverEffect):
return hover
return HoverEffect()
def _ensure_tooltip_style(
style: Any = None,
aliases: Optional[Dict[str, str]] = None,
) -> "TooltipStyle":
"""Normalize *style* to a :class:`TooltipStyle`."""
if style is None:
style = TooltipStyle()
elif isinstance(style, dict):
hover_raw = style.pop("hover", None)
style = TooltipStyle(**style)
if hover_raw is not None:
style.hover = hover_raw
# Normalize hover sub-config
style.hover = _ensure_hover_effect(style.hover)
return style
# ============================================================================
# Value formatting
# ============================================================================
def _format_value(val: Any) -> str:
"""Format a single value for tooltip display."""
if val is None:
return ""
if isinstance(val, float):
if val != val: # NaN
return ""
if val == int(val) and abs(val) < 1e15:
return f"{int(val):,}".replace(",", "\u202f")
return f"{val:g}"
if isinstance(val, (int, np.integer)):
return f"{int(val):,}".replace(",", "\u202f")
return str(val)
# ============================================================================
# Tooltip data builders
# ============================================================================
def auto_detect_columns(gdf: Any) -> List[str]:
"""Select displayable columns from a GeoDataFrame."""
return [
c for c in gdf.columns
if c not in _INTERNAL_COLUMNS and not c.startswith("_")
]
def _build_tooltip_map(
gdf: Any,
columns: List[str],
aliases: Optional[Dict[str, str]] = None,
id_prefix: str = "",
) -> Dict[str, str]:
"""Build a mapping ``{svg_element_id: json_tooltip_data}``.
Parameters
----------
gdf : GeoDataFrame
Source data (same GDF that was passed to ``ChoroplethLayer`` /
``ProportionalLayer``).
columns : list of str
Column names to include in each tooltip.
aliases : dict, optional
Column name to display label mapping.
id_prefix : str
SVG id prefix (e.g. ``"choro-1-"`` or ``"prop-2-"``).
Returns
-------
dict
``{element_id: json_string}`` ready for ``data-tooltip``
attribute injection.
"""
aliases = aliases or {}
result: Dict[str, str] = {}
for idx, row in gdf.iterrows():
data = {}
for col in columns:
if col in row.index and col != "geometry":
label = aliases.get(col, col)
data[label] = _format_value(row[col])
if not data:
continue
json_str = json.dumps(data, ensure_ascii=False)
eid = f"{id_prefix}{idx}"
result[eid] = json_str
return result
# ============================================================================
# JavaScript template
# ============================================================================
def _build_hover_css(hover: HoverEffect) -> str:
"""Build ``<style>`` block for hover CSS transitions."""
if not hover.enabled:
return ""
# Build the list of properties that actually change on hover,
# so we only transition those, NOT "all" which can cause
# fill/color artefacts when the DOM is reordered for z-order.
transition_props = []
hover_rules = []
if hover.opacity < 1.0:
transition_props.append("opacity")
hover_rules.append(f" opacity: {hover.opacity};")
if hover.scale != 1.0:
transition_props.append("transform")
hover_rules.append(f" transform: scale({hover.scale});")
hover_rules.append(" transform-origin: center;")
hover_rules.append(" transform-box: fill-box;")
if hover.shadow:
transition_props.append("filter")
hover_rules.append(
f" filter: drop-shadow({hover.shadow_dx}px {hover.shadow_dy}px "
f"{hover.shadow_blur}px {hover.shadow_color});"
)
if hover.stroke_width_boost > 0:
transition_props.append("stroke-width")
hover_rules.append(
f" stroke-width: calc(var(--orig-sw, 0.5) + {hover.stroke_width_boost});"
)
if hover.stroke_color:
transition_props.append("stroke")
hover_rules.append(f" stroke: {hover.stroke_color};")
if not hover_rules:
return ""
# Extract timing function from the user-provided transition string
# (strip leading "all " if present)
timing = hover.transition
if timing.startswith("all "):
timing = timing[4:]
transition_value = ", ".join(f"{p} {timing}" for p in transition_props)
css = f"""<style>
[data-tooltip] {{
cursor: pointer;
transition: {transition_value};
}}
[data-tooltip]:hover {{
{chr(10).join(hover_rules)}
}}
</style>"""
return css
def _build_tooltip_script(style: TooltipStyle) -> str:
"""Build the embedded ``<script>`` and ``<style>`` blocks."""
hover = style.hover
hover_css = _build_hover_css(hover)
# stroke-width-boost needs JS to read original stroke-width
sw_init_js = ""
if hover.enabled and hover.stroke_width_boost > 0:
sw_init_js = """
var sw=el.getAttribute('stroke-width')||'0.5';
el.style.setProperty('--orig-sw',sw);"""
return hover_css + f'''
<script type="text/ecmascript"><![CDATA[
(function(){{
var svg=document.currentScript.closest('svg')||document.querySelector('svg');
if(!svg)return;
var ns='http://www.w3.org/2000/svg';
// Tooltip group
var tt=document.createElementNS(ns,'g');
tt.setAttribute('visibility','hidden');
tt.setAttribute('pointer-events','none');
var bg=document.createElementNS(ns,'rect');
bg.setAttribute('fill','{style.background}');
bg.setAttribute('rx','{style.border_radius}');
bg.setAttribute('ry','{style.border_radius}');
bg.setAttribute('stroke','{style.border_color}');
bg.setAttribute('stroke-width','0.5');
tt.appendChild(bg);
var txt=document.createElementNS(ns,'text');
txt.setAttribute('fill','{style.text_color}');
txt.setAttribute('font-size','{style.font_size}');
txt.setAttribute('font-family','{style.font_family}');
tt.appendChild(txt);
svg.appendChild(tt);
svg.querySelectorAll('[data-tooltip]').forEach(function(el){{
{sw_init_js}
// Remember original position for z-order restore
var origNextSibling=el.nextElementSibling;
var origParent=el.parentNode;
el.addEventListener('mouseenter',function(){{
// Bring to front: move to last child of parent
origNextSibling=el.nextElementSibling;
origParent=el.parentNode;
if(origParent)origParent.appendChild(el);
var d;
try{{d=JSON.parse(el.getAttribute('data-tooltip'));}}catch(e){{return;}}
while(txt.firstChild)txt.removeChild(txt.firstChild);
var keys=Object.keys(d);
keys.forEach(function(k,i){{
var tsK=document.createElementNS(ns,'tspan');
tsK.setAttribute('font-weight','{style.key_weight}');
tsK.textContent=k+' : ';
if(i===0){{tsK.setAttribute('x','8');tsK.setAttribute('dy','15');}}
else{{tsK.setAttribute('x','8');tsK.setAttribute('dy','17');}}
txt.appendChild(tsK);
var tsV=document.createElementNS(ns,'tspan');
tsV.setAttribute('font-weight','400');
tsV.textContent=d[k];
txt.appendChild(tsV);
}});
var bb=txt.getBBox();
bg.setAttribute('x',String(bb.x-8));
bg.setAttribute('y',String(bb.y-6));
bg.setAttribute('width',String(bb.width+16));
bg.setAttribute('height',String(bb.height+12));
tt.setAttribute('visibility','visible');
}});
el.addEventListener('mousemove',function(e){{
var pt=svg.createSVGPoint();pt.x=e.clientX;pt.y=e.clientY;
var sp=pt.matrixTransform(svg.getScreenCTM().inverse());
var svgW=svg.viewBox.baseVal.width||svg.getBBox().width;
var offX=(sp.x>svgW*0.7)?-parseFloat(bg.getAttribute('width')||0)-15:15;
tt.setAttribute('transform','translate('+(sp.x+offX)+','+(sp.y-25)+')');
}});
el.addEventListener('mouseleave',function(){{
tt.setAttribute('visibility','hidden');
// Restore original z-order
if(origParent){{
if(origNextSibling)origParent.insertBefore(el,origNextSibling);
else origParent.appendChild(el);
}}
}});
}});
}})();
]]></script>'''
# ============================================================================
# SVG post-processing
# ============================================================================
def inject_tooltips(
svg_str: str,
tooltip_maps: List[Dict[str, str]],
style: Optional[TooltipStyle] = None,
) -> str:
"""Inject ``data-tooltip`` attributes and script into SVG markup.
Parameters
----------
svg_str : str
Raw SVG string from ``Map.to_string()``.
tooltip_maps : list of dict
Each dict maps ``{element_id: json_tooltip_string}``.
style : TooltipStyle, optional
Tooltip appearance.
Returns
-------
str
Modified SVG with interactive tooltips.
"""
style = style or TooltipStyle()
# Merge all tooltip maps into one
merged: Dict[str, str] = {}
for tm in tooltip_maps:
merged.update(tm)
if not merged:
return svg_str
# Register SVG namespace to avoid ns0: prefix in output
ET.register_namespace("", "http://www.w3.org/2000/svg")
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
root = ET.fromstring(svg_str)
# Walk all elements and attach data-tooltip where id matches
_walk_and_attach(root, merged)
# Serialize back
svg_out = ET.tostring(root, encoding="unicode", xml_declaration=True)
# Inject script before closing </svg> (handle namespace prefix)
script = _build_tooltip_script(style)
# ET may output </svg> or </ns0:svg> depending on namespace registration
import re
svg_out = re.sub(r'</(?:\w+:)?svg\s*>', script + "\n</svg>", svg_out)
return svg_out
def _walk_and_attach(elem: ET.Element, tooltip_map: Dict[str, str]) -> None:
"""Recursively walk the SVG tree and attach data-tooltip attributes."""
eid = elem.get("id", "")
if eid and eid in tooltip_map:
elem.set("data-tooltip", tooltip_map[eid])
for child in elem:
_walk_and_attach(child, tooltip_map)
# ============================================================================
# InteractiveMap result
# ============================================================================
[docs]
class InteractiveMap:
"""Interactive SVG map with hover tooltips.
Auto-renders in Jupyter via ``_repr_html_()`` and can be
saved as a standalone HTML file.
"""
[docs]
def __init__(self, svg_content: str):
self._svg = svg_content
def _repr_html_(self) -> str:
"""Jupyter notebook HTML display."""
return (
'<div style="display:inline-block;position:relative;">'
f'{self._svg}</div>'
)
[docs]
def save(self, path: str, title: str = "Interactive Map") -> None:
"""Save as standalone HTML file.
Parameters
----------
path : str
Output file path.
title : str
HTML page title.
"""
import html as html_module
html_content = (
"<!DOCTYPE html>\n<html>\n<head><meta charset='utf-8'>"
f"<title>{html_module.escape(title)}</title>\n"
"<style>"
"body{margin:0;display:flex;justify-content:center;"
"align-items:center;min-height:100vh;background:#f5f5f5}"
"svg{max-width:95vw;height:auto}"
"</style>\n</head>\n<body>\n"
f"{self._svg}\n</body>\n</html>"
)
with open(path, "w", encoding="utf-8") as f:
f.write(html_content)
@property
def svg(self) -> str:
"""Raw SVG markup with embedded JavaScript."""
return self._svg