# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Style helpers for pyCSAMT map renderers."""
from __future__ import annotations
import math
from collections.abc import Sequence
from .config import MapTheme
# "Unclassified" band: shown wherever a geology legend leaves a gap in its
# resistivity coverage (see :func:`geology_colorscale`) rather than letting
# Plotly interpolate a meaningless gradient between two unrelated
# lithology colours.
GEOLOGY_GAP_COLOR = "#9aa0a6"
PLOTLY_CMAP_REMAP: dict[str, str] = {
"coolwarm": "balance",
"seismic": "rdbu",
"terrain": "earth",
"YlOrRd": "ylorrd",
"RdBu_r": "rdbu_r",
"copper": "thermal",
"gnuplot2": "turbid",
"tab10": "plotly3",
"tab20": "plasma",
}
THEME_COLORS: dict[MapTheme, dict[str, str]] = {
"dark": {
"paper": "#1e1e2e",
"plot": "#181825",
"text": "#cdd6f4",
"grid": "#313244",
"accent": "#89b4fa",
},
"light": {
"paper": "#eff1f5",
"plot": "#ffffff",
"text": "#4c4f69",
"grid": "#ccd0da",
"accent": "#1e66f5",
},
"publication": {
"paper": "#ffffff",
"plot": "#ffffff",
"text": "#111111",
"grid": "#cccccc",
"accent": "#1565c0",
},
}
def _plotly_named(name: str) -> bool:
"""``True`` when Plotly has a built-in colour scale called *name*
(case-insensitive; any name may end in ``_r``)."""
try:
from plotly.colors import named_colorscales
except Exception: # plotly missing: nothing to validate against
return True
base = name.lower()
base = base[:-2] if base.endswith("_r") else base
return base in named_colorscales()
def _sampled_colorscale(cmap: str, n: int = 11) -> list | None:
"""A matplotlib colormap as an explicit Plotly colour scale."""
try:
import matplotlib
cm = matplotlib.colormaps[cmap]
except Exception:
return None
out = []
for i in range(n):
pos = i / (n - 1)
r, g, b, _a = cm(pos)
out.append([round(pos, 4),
f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})"])
return out
[docs]
def to_plotly_cmap(
cmap: str | None,
fallback: str = "plasma",
):
"""Return a Plotly colour scale for *cmap*.
A Plotly name when Plotly has one; otherwise the matplotlib colormap
sampled into an explicit scale, so every entry of
:data:`pycsamt.api.colormaps.COLORMAPS` (``gist_earth``, ``bwr``,
``Spectral_r`` …) renders in the 3-D and map views instead of failing.
"""
if not cmap:
return fallback
if not isinstance(cmap, str):
return cmap # already an explicit scale
name = PLOTLY_CMAP_REMAP.get(cmap, cmap)
if _plotly_named(name):
return name
return _sampled_colorscale(cmap) or fallback
# ---------------------------------------------------------------------------
# Geology-legend (Interpretation overlay) discrete colour banding
# ---------------------------------------------------------------------------
# A "band" is a plain ``(rho_min, rho_max, hex_color)`` tuple, Ω·m linear.
[docs]
def geology_crange(
bands: Sequence[tuple[float, float, str]],
) -> tuple[float, float]:
"""Return the ``(log10_lo, log10_hi)`` span covered by *bands*.
*bands* is a plain, format-agnostic ``(rho_min, rho_max, color)`` list
in linear Ω·m -- the app layer builds it from a
:class:`pycsamt.geology.lithology.RockDatabase` /
:class:`pycsamt.format.geology.GeologyLegend`; this module stays free
of any format/geology import. Non-positive or degenerate bands are
ignored. Falls back to ``(0.0, 1.0)`` when no band is usable.
"""
usable = [
(float(b[0]), float(b[1]))
for b in bands
if float(b[0]) > 0 and float(b[1]) > float(b[0])
]
if not usable:
return (0.0, 1.0)
los = [lo for lo, _ in usable]
his = [hi for _, hi in usable]
return (math.log10(min(los)), math.log10(max(his)))
[docs]
def geology_colorscale(
bands: Sequence[tuple[float, float, str]],
*,
log_lo: float | None = None,
log_hi: float | None = None,
gap_color: str = GEOLOGY_GAP_COLOR,
textured: dict[str, int] | None = None,
) -> list[list]:
"""Build a hard-stepped Plotly colorscale from a geology legend.
Returns ``[[t, color], ...]`` stops with ``t`` in ``[0, 1]`` normalized
against ``(log_lo, log_hi)`` (defaults to :func:`geology_crange` of
*bands*), using repeated ``t`` values at each band boundary so Plotly
renders flat colour bands instead of a smooth gradient -- pass the
same ``(log_lo, log_hi)`` as ``cmin``/``cmax`` on the trace, in
log10(Ω·m), so the bands line up with the data. Any part of the
``[log_lo, log_hi]`` span not covered by a band (including gaps
between bands) renders as *gap_color* rather than an interpolated
blend of neighbouring lithology colours.
*textured* optionally maps a band's name (its 4th tuple element) to
a stop count -- that band's sub-range is filled with an N-stop
:func:`pattern_band_stops` gradient (light tint of the band colour
-> full colour) instead of one flat colour. On its own this still
just renders a gradient; the actual *pattern* only appears once the
caller also overrides each cell's colour-axis value within that
sub-range by a real spatial pattern-stencil sample (see
:func:`pycsamt.map.volume._inject_pattern_values`) instead of its
raw resistivity -- this function only prepares the palette that
sampling then reads from.
"""
lo, hi = (log_lo, log_hi) if log_lo is not None and log_hi is not None \
else geology_crange(bands)
if not (hi > lo):
hi = lo + 1.0
def _t(value: float) -> float:
return min(1.0, max(0.0, (value - lo) / (hi - lo)))
textured = textured or {}
segments: list[tuple[float, float, str, str]] = []
for band in bands:
rho_min, rho_max, color = float(band[0]), float(band[1]), band[2]
name = str(band[3]) if len(band) >= 4 else ""
if not (rho_min > 0 and rho_max > rho_min):
continue
t0, t1 = _t(math.log10(rho_min)), _t(math.log10(rho_max))
if t1 <= t0:
continue
segments.append((t0, t1, str(color), name))
segments.sort(key=lambda s: s[0])
stops: list[list] = []
cursor = 0.0
for t0, t1, color, name in segments:
if t0 > cursor:
stops += [[cursor, gap_color], [t0, gap_color]]
n_stops = textured.get(name) if name else None
if n_stops and n_stops > 1:
palette = pattern_band_stops(color, int(n_stops))
for i, stop_color in enumerate(palette):
tt = t0 + (t1 - t0) * (i / (len(palette) - 1))
stops.append([tt, stop_color])
else:
stops += [[t0, color], [t1, color]]
cursor = max(cursor, t1)
if cursor < 1.0:
stops += [[cursor, gap_color], [1.0, gap_color]]
if not stops:
stops = [[0.0, gap_color], [1.0, gap_color]]
stops[0][0] = 0.0
stops[-1][0] = 1.0
return stops
[docs]
def pattern_band_stops(base_color: str, n: int = 16) -> list[str]:
"""Return *n* hex colours from a light tint of *base_color* ("no
ink" -- a pattern stencil's background) to the full *base_color*
("full ink"), for texturing one geology band with a pattern
stencil sampled to a per-cell density in ``[0, 1]``.
A tile is read purely as a grayscale/alpha *stencil* (see
:func:`pycsamt.geology.patterns` tile handling), not as a literal
colour source -- the legend's own assigned colour is what actually
tints the rendered pattern, so a hatch/stipple/brick swatch reads
in the same colour its legend chip already shows, whatever the
source pack's original ink colour was.
"""
n = max(2, int(n))
r, g, b = _hex_to_rgb(base_color)
lr = round(r + (255 - r) * 0.85)
lg = round(g + (255 - g) * 0.85)
lb = round(b + (255 - b) * 0.85)
out = []
for i in range(n):
t = i / (n - 1)
rr = round(lr + (r - lr) * t)
gg = round(lg + (g - lg) * t)
bb = round(lb + (b - lb) * t)
out.append(f"#{rr:02X}{gg:02X}{bb:02X}")
return out
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
text = str(hex_color).lstrip("#")
if len(text) != 6:
return (128, 128, 128)
try:
r, g, b = (int(text[i : i + 2], 16) for i in (0, 2, 4))
except ValueError:
return (128, 128, 128)
return (r, g, b)
[docs]
def geology_colorbar_ticks(
bands: Sequence[tuple[float, float, str] | tuple[float, float, str, str]],
) -> tuple[list[float], list[str]]:
"""Return ``(tickvals, ticktext)`` labelling each band by name.
*bands* is the same list :func:`geology_colorscale` takes, with an
optional 4th element, the band's display name (e.g. a rock name).
Each tick sits at the band's own log10 geometric-mean resistivity so
it lands inside its colour, not on a boundary shared with its
neighbour. Bands with no name (a plain 3-tuple) are skipped -- pass
this straight to a trace's ``colorbar=dict(tickvals=..., ticktext=
...)`` in place of the default numeric Ω·m ticks, "masking" the
resistivity reading with the geology legend itself.
"""
tickvals: list[float] = []
ticktext: list[str] = []
for band in bands:
if len(band) < 4 or not str(band[3]).strip():
continue
rho_min, rho_max = float(band[0]), float(band[1])
if not (rho_min > 0 and rho_max > rho_min):
continue
mid = math.sqrt(rho_min * rho_max)
tickvals.append(math.log10(mid))
ticktext.append(str(band[3]))
return tickvals, ticktext
[docs]
def geology_legend_shapes_annotations(
bands: Sequence[tuple[float, float, str] | tuple[float, float, str, str]],
*,
x: float = 1.02,
y_top: float = 0.95,
row_height: float = 0.05,
min_row_height: float = 0.026,
font_size: int = 11,
min_font_size: int = 8,
) -> tuple[list[dict], list[dict]]:
"""Build a non-overlapping on-canvas geology legend: one coloured
"chip" annotation per band, stacked at a fixed row height in the
figure's right margin (``xref="paper"``, so the caller must reserve
real margin space -- e.g. ``margin=dict(r=220)``).
Unlike labelling each band at its own resistivity position on a
colourbar (:func:`geology_colorbar_ticks`), row position here is by
*index*, not by value -- two bands whose resistivity ranges sit
close together (or a legend with many entries on a short colourbar)
can never overlap, only ever crowd, and rows shrink toward
*min_row_height*/*min_font_size* before that happens. If even the
minimum still cannot fit every band in ``[0, y_top]``, the remainder
collapses into one final "+N more" row rather than overlapping or
running off the canvas.
Returns ``(shapes, annotations)`` -- *shapes* is always empty (the
chip's own background covers the swatch), kept for API symmetry with
a future non-text legend element.
"""
entries: list[tuple[str, str]] = []
for band in bands:
name = (
str(band[3]).strip()
if len(band) >= 4 and str(band[3]).strip()
else f"{float(band[0]):g}–{float(band[1]):g} Ω·m"
)
entries.append((name, str(band[2])))
if not entries:
return [], []
n = len(entries)
available = max(y_top - 0.02, min_row_height)
# Row height shrinks toward min_row_height as more bands need to
# fit; once even that minimum can't seat all of them, cap the row
# *count* instead (one slot reserved for a "+N more" summary) so
# rows never have to overlap to make room.
rh = max(min_row_height, min(row_height, available / n))
max_rows = max(1, int(available / rh))
fs = font_size if rh >= row_height else max(
min_font_size, round(font_size * (rh / row_height))
)
truncated = n > max_rows
n_shown = (max_rows - 1) if truncated else n
n_shown = max(0, n_shown)
annotations: list[dict] = []
for i in range(n_shown):
name, color = entries[i]
annotations.append(
dict(
x=x, y=y_top - i * rh, xref="paper", yref="paper",
xanchor="left", yanchor="middle",
text=f" {name} ", showarrow=False, align="left",
font=dict(size=fs, color=_contrast_text_color(color)),
bgcolor=color, bordercolor="rgba(0,0,0,0.25)",
borderwidth=1, borderpad=3,
)
)
if truncated:
annotations.append(
dict(
x=x, y=y_top - n_shown * rh, xref="paper", yref="paper",
xanchor="left", yanchor="middle",
text=f"+{n - n_shown} more…", showarrow=False,
font=dict(size=fs), align="left",
)
)
return [], annotations
def _contrast_text_color(hex_color: str) -> str:
"""Black or white, whichever reads better on *hex_color*."""
text = str(hex_color).lstrip("#")
if len(text) != 6:
return "#111111"
try:
r, g, b = (int(text[i : i + 2], 16) for i in (0, 2, 4))
except ValueError:
return "#111111"
luminance = 0.299 * r + 0.587 * g + 0.114 * b
return "#111111" if luminance > 140 else "#f5f5f5"
[docs]
def theme_colors(theme: MapTheme = "light") -> dict[str, str]:
"""Return a copied color palette for *theme*."""
colors = THEME_COLORS.get(theme, THEME_COLORS["light"])
return dict(colors)
[docs]
def is_dark_theme(theme: MapTheme = "light") -> bool:
"""Return True for dark map styling."""
return theme == "dark"