Spaces:
Running
on
Zero
Running
on
Zero
"""legend_builders.py | |
==================== | |
Minimal‑dependency helpers that generate **static** legend HTML + CSS matching | |
DataMapPlot’s own class names. Drop the returned strings straight into | |
``create_interactive_plot(custom_html=…, custom_css=…)``. | |
Highlights | |
---------- | |
* **continuous_legend_html_css** – full control over ticks, label, size & | |
absolute position (via an *anchor* keyword). | |
* **categorical_legend_html_css** – swatch legend with optional title, flexible | |
anchor, row/column layout and custom swatch size. | |
Both helpers return ``(html, css)`` so you can concatenate multiple legends. | |
No JavaScript is injected – they render statically but look native. If you | |
later add JS (e.g. DMP’s `ColorLegend` behaviour), the class names already fit. | |
""" | |
from __future__ import annotations | |
from typing import Dict, List, Sequence, Tuple, Union | |
from datetime import datetime, date | |
import matplotlib.cm as _cm | |
from matplotlib.colors import to_hex, to_rgb | |
Colour = Union[str, tuple] | |
__all__ = ["continuous_legend_html_css", "categorical_legend_html_css"] | |
# --------------------------------------------------------------------------- | |
# helpers | |
# --------------------------------------------------------------------------- | |
def _hex(c: Colour) -> str: | |
"""Convert *c* to #RRGGBB hex (handles any Matplotlib‑parsable colour).""" | |
return c if isinstance(c, str) else to_hex(to_rgb(c)) | |
def _gradient(cmap: Union[str, _cm.Colormap, Sequence[str]], *, vertical: bool = True) -> str: | |
"""Return a CSS linear‑gradient from a Matplotlib cmap or explicit colour list.""" | |
if isinstance(cmap, (list, tuple)): | |
stops = [_hex(c) for c in cmap] | |
else: | |
cmap = _cm.get_cmap(cmap) if isinstance(cmap, str) else cmap | |
stops = [to_hex(cmap(i / 255)) for i in range(256)] | |
direction = "to top" if vertical else "to right" | |
return f"linear-gradient({direction}, {', '.join(stops)})" | |
_ANCHOR_CSS: Dict[str, str] = { | |
"top-left": "top:10px; left:10px;", | |
"top-right": "top:10px; right:10px;", | |
"bottom-left": "bottom:10px; left:10px;", | |
"bottom-right": "bottom:10px; right:10px;", | |
"middle-left": "top:50%; left:10px; transform:translateY(-50%);", | |
"middle-right": "top:50%; right:10px; transform:translateY(-50%);", | |
"middle-center": "top:50%; left:50%; transform:translate(-50%,-50%);", | |
} | |
# --------------------------------------------------------------------------- | |
# continuous legend | |
# --------------------------------------------------------------------------- | |
def continuous_legend_html_css( | |
cmap: Union[str, _cm.Colormap, Sequence[str]], | |
vmin: Union[int, float, datetime, date], | |
vmax: Union[int, float, datetime, date], | |
*, | |
ticks: Sequence[Union[int, float, datetime, date]] | None = None, | |
label: str | None = None, | |
bar_size: tuple[int, int] = (10, 200), | |
anchor: str = "top-right", | |
container_id: str = "dmp-colorbar", | |
) -> Tuple[str, str]: | |
"""Return *(html, css)* snippet for a static colour‑bar legend.""" | |
# ---------- ticks ----------------------------------------------------- | |
if ticks is None: | |
ticks = [vmin + (vmax - vmin) * i / 4 for i in range(5)] # type: ignore | |
def _fmt(val): | |
if isinstance(val, (datetime, date)): | |
return val.strftime("%Y") | |
sci = max(abs(float(vmin)), abs(float(vmax))) >= 1e5 or 0 < abs(float(vmin)) <= 1e-4 | |
if sci: | |
return f"{val:.1e}" | |
return f"{val:.0f}" if float(val).is_integer() else f"{val:.2f}" | |
tick_labels = [_fmt(t) for t in ticks] | |
# relative positions (0% top, 100% bottom) ----------------------------- | |
def _rel(val): | |
if isinstance(val, (datetime, date)): | |
rng = (ticks[-1] - ticks[0]).total_seconds() or 1 | |
return (ticks[-1] - val).total_seconds() / rng * 100 | |
rng = float(ticks[-1] - ticks[0]) or 1 | |
return (ticks[-1] - val) / rng * 100 | |
# ---------- HTML ------------------------------------------------------ | |
w, h = bar_size | |
html: List[str] = [f'<div id="{container_id}" class="colorbar-container">'] | |
if label: | |
html.append( | |
f' <div class="colorbar-label" style="writing-mode:vertical-rl; transform:rotate(180deg); margin-right:8px;">{label}</div>' | |
) | |
html.append(f' <div class="colorbar" style="width:{w}px; height:{h}px; background:{_gradient(cmap)};"></div>') | |
html.append(' <div class="colorbar-tick-container">') | |
for pos, lab in zip([_rel(t) for t in ticks], tick_labels): | |
html.append( | |
f' <div class="colorbar-tick" style="top:{pos:.2f}%;">' | |
' <div class="colorbar-tick-line"></div>' | |
f' <div class="colorbar-tick-label">{lab}</div>' | |
' </div>' | |
) | |
html.extend([' </div>', '</div>']) | |
# ---------- CSS ------------------------------------------------------- | |
pos_css = _ANCHOR_CSS.get(anchor, _ANCHOR_CSS["top-right"]) | |
css = f""" | |
#{container_id} {{position:absolute; {pos_css} z-index:100; display:flex; align-items:center; gap:4px; padding:10px;}} | |
#{container_id} .colorbar-tick-container {{position:relative; width:40px; height:{h}px;}} | |
#{container_id} .colorbar-tick {{position:absolute; display:flex; align-items:center; gap:4px; transform:translateY(-50%); font-size:12px;}} | |
#{container_id} .colorbar-tick-line {{width:8px; height:1px; background:#333;}} | |
#{container_id} .colorbar-label {{font-size:12px;}} | |
""" | |
return "\n".join(html), css | |
# --------------------------------------------------------------------------- | |
# categorical legend | |
# --------------------------------------------------------------------------- | |
def categorical_legend_html_css( | |
color_mapping: Dict[str, Colour], | |
*, | |
title: str | None = None, | |
swatch: int = 12, | |
anchor: str = "bottom-left", | |
container_id: str = "dmp-catlegend", | |
rows: bool = True, | |
) -> Tuple[str, str]: | |
"""Return *(html, css)* for a swatch legend.""" | |
html: List[str] = [f'<div id="{container_id}" class="color-legend-container">'] | |
if title: | |
html.append(f' <div class="legend-title">{title}</div>') | |
for lbl, col in color_mapping.items(): | |
html.append( | |
' <div class="legend-item">' | |
f' <div class="color-swatch-box" style="background:{_hex(col)};"></div>' | |
f' <div class="legend-label">{lbl}</div>' | |
' </div>' | |
) | |
html.append('</div>') | |
pos_css = _ANCHOR_CSS.get(anchor, _ANCHOR_CSS["bottom-left"]) | |
css = f""" | |
#{container_id} {{position:absolute; {pos_css} z-index:100; display:flex; flex-direction:{'column' if rows else 'row'}; gap:4px; padding:10px;}} | |
#{container_id} .legend-title {{font-weight:bold; margin-bottom:4px;}} | |
#{container_id} .legend-item {{display:flex; align-items:center; gap:4px;}} | |
#{container_id} .color-swatch-box {{width:{swatch}px; height:{swatch}px; border-radius:2px; border:1px solid #555;}} | |
#{container_id} .legend-label {{font-size:12px;}} | |
""" | |
return "\n".join(html), css | |
# --------------------------------------------------------------------------- | |
# sample script for quick testing | |
# --------------------------------------------------------------------------- | |
if __name__ == "__main__": | |
# pip install datamapplot matplotlib numpy to run this demo | |
import numpy as np | |
from matplotlib import cm | |
import datamapplot as dmp | |
# dummy data ---------------------------------------------------------- | |
n = 400 | |
rng = np.random.default_rng(0) | |
coords = rng.normal(size=(n, 2)) | |
years = rng.integers(1990, 2025, size=n) | |
# quadrant labels ----------------------------------------------------- | |
quad = np.where(coords[:, 0] >= 0, | |
np.where(coords[:, 1] >= 0, "A", "D"), | |
np.where(coords[:, 1] >= 0, "B", "C")) | |
# colours ------------------------------------------------------------- | |
grey = "#bbbbbb" | |
cols = np.full(n, grey, dtype=object) | |
mask = rng.random(n) < 0.1 | |
vir = cm.get_cmap("viridis") | |
cols[mask] = [to_hex(vir((y - years.min())/(years.max()-years.min()))) for y in years[mask]] | |
# legends ------------------------------------------------------------- | |
html_bar, css_bar = continuous_legend_html_css( | |
vir, years.min(), years.max(), label="Year", anchor="middle-right", ticks=[1990, 2000, 2010, 2020, 2024] | |
) | |
html_cat, css_cat = categorical_legend_html_css( | |
{lbl: col for lbl, col in zip("ABCD", cm.tab10.colors)}, title="Quadrant", anchor="bottom-left" | |
) | |
custom_html = html_bar + html_cat | |
custom_css = css_bar + css_cat | |
# plot --------------------------------------------------------------- | |
plot = dmp.create_interactive_plot( | |
coords, quad, | |
hover_text=np.arange(n).astype(str), | |
marker_color_array=cols, | |
custom_html=custom_html, | |
custom_css=custom_css, | |
) | |
# In Jupyter this shows automatically; otherwise save: | |
# with open("demo.html", "w") as f: | |
# f.write(str(plot)) | |
print("Demo plot generated – view in a notebook or open the saved HTML.") | |