"""Interactive Plotly visualizations for analysis tools.
This module provides Plotly-based interactive plotting functions for dimensionality
reduction visualizations. Functions create standalone HTML files with zoom, pan,
hover, and export capabilities.
Plotly is an optional dependency. If not installed, functions will raise ImportError
with installation instructions.
Usage Example
-------------
::
import numpy as np
from pathlib import Path
from build_tools.syllable_analysis.plotting.interactive import (
create_interactive_scatter,
save_interactive_html,
PLOTLY_AVAILABLE
)
if not PLOTLY_AVAILABLE:
print("Plotly not installed - skipping interactive visualization")
else:
# Create visualization
records = [
{"syllable": "ka", "frequency": 100, "features": {...}},
...
]
tsne_coords = np.array([[...], [...]])
fig = create_interactive_scatter(records, tsne_coords)
# Save to HTML
output_path = Path("_working/output.html")
save_interactive_html(fig, output_path, perplexity=30, random_state=42)
"""
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import numpy as np # type: ignore[import-not-found]
from .styles import (
DEFAULT_COLORSCALE,
DEFAULT_EXPORT_HEIGHT,
DEFAULT_EXPORT_SCALE,
DEFAULT_EXPORT_WIDTH,
DEFAULT_MARKER_LINE_WIDTH,
DEFAULT_PLOT_HEIGHT,
DEFAULT_PLOT_MIN_WIDTH,
INTERACTIVE_TITLE_FONT_SIZE,
)
# Try to import Plotly - this is an optional dependency
try:
import plotly.graph_objects as go # type: ignore[import-not-found]
PLOTLY_AVAILABLE = True
except ImportError:
PLOTLY_AVAILABLE = False
go = None # type: ignore[assignment]
[docs]
def create_interactive_scatter(
records: List[Dict],
tsne_coords: np.ndarray,
title: str = "t-SNE: Feature Signature Space (Interactive)",
) -> "go.Figure":
"""Create interactive Plotly scatter plot of t-SNE coordinates.
Generates an interactive HTML-compatible visualization with rich hover tooltips,
zoom/pan controls, and export capabilities. Points are sized (log scale) and
colored by frequency.
Args:
records: List of annotated syllable records. Each must contain:
- syllable (str): Syllable text
- frequency (int): Occurrence count
- features (dict): Boolean feature flags (12 features)
tsne_coords: 2D coordinate array of shape (n_samples, 2) from t-SNE
title: Plot title (default: "t-SNE: Feature Signature Space (Interactive)")
Returns:
Plotly Figure object with configured interactive scatter plot
Raises:
ImportError: If Plotly is not installed
ValueError: If inputs are invalid or lengths don't match
Example:
>>> records = [
... {"syllable": "ka", "frequency": 100, "features": {"contains_plosive": True}},
... {"syllable": "mi", "frequency": 50, "features": {"contains_nasal": True}},
... ]
>>> coords = np.array([[1.0, 2.0], [3.0, 4.0]])
>>> fig = create_interactive_scatter(records, coords)
>>> fig.show() # Opens in browser
Notes:
- Point size uses log1p scale for better visibility across frequency ranges
- Hover text shows syllable, frequency, feature count, and up to 4 features
- If more than 4 features, shows "...+N more" truncation
- Viridis colorscale provides perceptually uniform coloring
- Fixed height (900px) with responsive width for consistent aspect ratio
- Plotly CDN used when saving to HTML for smaller file size
"""
if not PLOTLY_AVAILABLE:
raise ImportError(
"Plotly is required for interactive visualization. " "Install with: pip install plotly"
)
# Validate inputs
if tsne_coords.ndim != 2 or tsne_coords.shape[1] != 2:
raise ValueError(
f"tsne_coords must be 2D array with shape (n, 2), got shape {tsne_coords.shape}"
)
if len(records) != tsne_coords.shape[0]:
raise ValueError(
f"records length ({len(records)}) must match "
f"tsne_coords rows ({tsne_coords.shape[0]})"
)
# Extract data for visualization
syllables = [r["syllable"] for r in records]
frequencies = np.array([r["frequency"] for r in records])
# Build rich hover text with syllable details
hover_texts = []
for record in records:
hover_text = build_hover_text(record, max_features=4)
hover_texts.append(hover_text)
# Create figure with main scatter trace
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=tsne_coords[:, 0],
y=tsne_coords[:, 1],
mode="markers",
marker=dict(
size=np.log1p(frequencies) * 3, # Log scale for better visibility
color=frequencies,
colorscale=DEFAULT_COLORSCALE,
showscale=True,
colorbar=dict(
title=dict(text="Frequency", side="right"),
len=0.75,
thickness=15,
xpad=10,
),
line=dict(width=DEFAULT_MARKER_LINE_WIDTH, color="black"),
opacity=0.7,
),
text=syllables,
hovertext=hover_texts,
hoverinfo="text",
customdata=[[i] for i in range(len(records))], # Store index for future use
name="Syllables",
)
)
# Configure layout for optimal viewing with responsive sizing
fig.update_layout(
title={
"text": title,
"x": 0.5,
"xanchor": "center",
"font": {"size": INTERACTIVE_TITLE_FONT_SIZE, "family": "Arial, sans-serif"},
},
xaxis_title="t-SNE Dimension 1",
yaxis_title="t-SNE Dimension 2",
hovermode="closest",
autosize=True, # Enable responsive sizing
height=DEFAULT_PLOT_HEIGHT,
template="plotly_white",
showlegend=True,
)
return fig
[docs]
def build_hover_text(record: Dict, max_features: int = 4) -> str:
"""Build rich hover text for a single syllable record.
Creates HTML-formatted hover text showing syllable details, frequency,
and active features. Features are truncated if more than max_features
are present.
Args:
record: Syllable record with 'syllable', 'frequency', 'features' keys
max_features: Maximum features to show before truncating (default: 4)
Returns:
HTML-formatted hover text string
Example:
>>> record = {
... "syllable": "kran",
... "frequency": 150,
... "features": {
... "contains_plosive": True,
... "contains_liquid": True,
... "contains_nasal": True,
... "starts_with_cluster": True,
... "ends_with_nasal": True,
... }
... }
>>> text = build_hover_text(record, max_features=4)
>>> print(text)
<b>kran</b><br>Frequency: 150<br>Features: 5/12<br><i>contains_plosive, ...</i><br>...
Notes:
- Syllable shown in bold
- Frequency shown with comma separators (e.g., "1,234")
- Feature count shows active/total (e.g., "5/12")
- First N features shown in italics
- If more than N features, shows "+M more" truncation message
"""
active_features = [feat for feat, val in record["features"].items() if val]
hover_text = (
f"<b>{record['syllable']}</b><br>"
f"Frequency: {record['frequency']:,}<br>"
f"Features: {len(active_features)}/12<br>"
f"<i>{', '.join(active_features[:max_features])}</i>"
)
if len(active_features) > max_features:
hover_text += f"<br><i>... +{len(active_features) - max_features} more</i>"
return hover_text
[docs]
def save_interactive_html(
fig: "go.Figure",
output_path: Path,
perplexity: int,
random_state: int,
min_width: int = DEFAULT_PLOT_MIN_WIDTH,
) -> None:
"""Save interactive Plotly figure as standalone HTML.
Creates a self-contained HTML file with embedded Plotly visualization that can be:
- Opened directly in any web browser
- Shared with collaborators
- Embedded in reports or documentation
- Explored with zoom, pan, hover, and export controls
The HTML file uses Plotly CDN for JavaScript dependencies (smaller file size)
and includes responsive CSS and a metadata footer.
Args:
fig: Plotly Figure object from create_interactive_scatter()
output_path: Output HTML file path (parent directory must exist)
perplexity: t-SNE perplexity parameter (for metadata footer)
random_state: Random seed used (for metadata footer)
min_width: Minimum width constraint in pixels (default: 1250)
Raises:
ImportError: If Plotly is not installed
FileNotFoundError: If parent directory doesn't exist
ValueError: If output_path doesn't end with .html
Example:
>>> fig = create_interactive_scatter(records, tsne_coords)
>>> output_path = Path("_working/visualization.html")
>>> save_interactive_html(fig, output_path, perplexity=30, random_state=42)
Notes:
- Plotly CDN used for smaller file size vs. full JS bundle
- Mode bar configured with additional tools (hoverclosest, hovercompare)
- Export to PNG button configured for high-resolution (1600x1200, 2x scale)
- Responsive CSS ensures minimum width of 1250px
- Metadata footer includes algorithm parameters and generation time
"""
if not PLOTLY_AVAILABLE:
raise ImportError("Plotly is required for interactive visualization.")
# Validate output path
if not str(output_path).endswith(".html"):
raise ValueError(f"output_path must end with .html, got: {output_path}")
if not output_path.parent.exists():
raise FileNotFoundError(f"Parent directory does not exist: {output_path.parent}")
# Extract timestamp from filename or generate new one
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Save as standalone HTML with configuration
fig.write_html(
str(output_path),
include_plotlyjs="cdn", # Use CDN for smaller file size
config={
"displayModeBar": True,
"displaylogo": False,
"modeBarButtonsToAdd": ["hoverclosest", "hovercompare"],
"toImageButtonOptions": {
"format": "png",
"filename": f"tsne_interactive_{timestamp}",
"height": DEFAULT_EXPORT_HEIGHT,
"width": DEFAULT_EXPORT_WIDTH,
"scale": DEFAULT_EXPORT_SCALE,
},
},
)
# Inject responsive CSS
html_content = output_path.read_text(encoding="utf-8")
html_content = inject_responsive_css(html_content, min_width=min_width)
output_path.write_text(html_content, encoding="utf-8")
# Append metadata footer to HTML
metadata_html = create_metadata_footer(perplexity, random_state)
with output_path.open("a", encoding="utf-8") as f:
f.write(metadata_html)
[docs]
def inject_responsive_css(
html_content: str,
min_width: int = DEFAULT_PLOT_MIN_WIDTH,
) -> str:
"""Inject responsive CSS into HTML content.
Adds CSS rules to ensure the plot has a minimum width and proper scrolling
behavior. This prevents the plot from becoming too narrow on small screens
while allowing horizontal scrolling when necessary.
Args:
html_content: Original HTML content from Plotly
min_width: Minimum width constraint in pixels (default: 1250)
Returns:
HTML content with injected CSS in <head> section
Example:
>>> html = "<html><head></head><body>...</body></html>"
>>> modified = inject_responsive_css(html, min_width=1250)
>>> "<style>" in modified
True
Notes:
- CSS is inserted after the opening <head> tag
- Sets body margin/padding to 0 for full-width layout
- Enables horizontal scrolling when plot exceeds viewport width
- Sets fixed height (900px) matching plot configuration
- Uses !important to override Plotly's inline styles
"""
responsive_css = f"""
<style>
body {{
margin: 0;
padding: 0;
overflow-x: auto;
}}
.plotly-graph-div {{
min-width: {min_width}px !important;
width: 100% !important;
height: {DEFAULT_PLOT_HEIGHT}px !important;
}}
</style>
"""
# Insert CSS after <head> tag
html_content = html_content.replace("<head>", f"<head>\n{responsive_css}")
return html_content