Skip to content

Commit 8ca63d9

Browse files
committed
feat: Add color keys to IPF and position HSV
1 parent 4003c6b commit 8ca63d9

3 files changed

Lines changed: 367 additions & 0 deletions

File tree

assets/05-peakindex-viz.css

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,3 +363,108 @@
363363
height: calc(100vh - 170px);
364364
overflow: auto;
365365
}
366+
367+
/* ==========================================================================
368+
IPF / color-key legend (sidebar widget)
369+
========================================================================== */
370+
371+
.pi-ipf-legend {
372+
padding: .55rem .55rem .65rem;
373+
background: var(--pi-fog);
374+
display: flex;
375+
flex-direction: column;
376+
align-items: center;
377+
gap: .35rem;
378+
}
379+
380+
.pi-ipf-legend--empty {
381+
padding: .5rem .65rem;
382+
align-items: flex-start;
383+
}
384+
385+
/* Square frame that contains the image + corner labels.
386+
Width derived from sidebar (240 px) minus padding; clamps for narrow sidebars. */
387+
.pi-ipf-legend-frame {
388+
position: relative;
389+
width: 100%;
390+
max-width: 200px;
391+
aspect-ratio: 1 / 1;
392+
user-select: none;
393+
}
394+
395+
.pi-ipf-legend-img {
396+
width: 100%;
397+
height: 100%;
398+
display: block;
399+
/* Pixel art interpolation to keep the IPF triangle crisp at small sizes */
400+
image-rendering: -webkit-optimize-contrast;
401+
image-rendering: crisp-edges;
402+
}
403+
.pi-ipf-legend-img--circle {
404+
border-radius: 50%;
405+
}
406+
407+
/* Corner labels for {001}, {101}, {111}.
408+
Coordinates match the geometry of make_cubic_ipf_triangle: triangle
409+
vertices live at the bottom-left (001), the right edge (101), and
410+
the top-right (111) corners of the square image. */
411+
.pi-ipf-corner-label {
412+
position: absolute;
413+
font-family: Lato, sans-serif;
414+
font-size: .68rem;
415+
font-weight: 700;
416+
color: var(--pi-slate);
417+
background: rgba(255, 255, 255, .85);
418+
padding: 1px 4px;
419+
border-radius: 2px;
420+
line-height: 1;
421+
pointer-events: none;
422+
white-space: nowrap;
423+
}
424+
.pi-ipf-corner-001 { left: -2px; bottom: -2px; }
425+
.pi-ipf-corner-101 { right: -2px; bottom: -2px; }
426+
.pi-ipf-corner-111 { right: -2px; top: -2px; }
427+
428+
/* HSV color-wheel axis labels.
429+
The wheel is a circle inscribed in the square frame; labels sit just
430+
outside the circle at the cardinal/diagonal compass directions.
431+
Layout: +X right, -X left, +Y top-left, -Y bottom-right,
432+
+Z bottom-left, -Z top-right (no labels on the vertical poles). */
433+
.pi-hsv-axis-label {
434+
position: absolute;
435+
font-family: Lato, sans-serif;
436+
font-size: .65rem;
437+
font-weight: 700;
438+
color: var(--pi-slate);
439+
background: rgba(255, 255, 255, .85);
440+
padding: 1px 3px;
441+
border-radius: 2px;
442+
line-height: 1;
443+
pointer-events: none;
444+
white-space: nowrap;
445+
transform: translate(-50%, -50%);
446+
}
447+
/* Cardinal axes (X) — sit on the circle's horizontal extremes */
448+
.pi-hsv-axis-px { left: 100%; top: 50%; transform: translate(-90%, -50%); }
449+
.pi-hsv-axis-nx { left: 0%; top: 50%; transform: translate(-10%, -50%); }
450+
/* Diagonals (Y, Z) — sit at ~45° on the circle.
451+
Inscribed-circle edge at 45° hits ~85.4% of half-frame; we nudge
452+
inward slightly to keep labels visually attached to the wheel. */
453+
.pi-hsv-axis-py { left: 14%; top: 14%; } /* upper-left = +Y */
454+
.pi-hsv-axis-ny { left: 86%; top: 86%; } /* lower-right = -Y */
455+
.pi-hsv-axis-pz { left: 14%; top: 86%; } /* lower-left = +Z */
456+
.pi-hsv-axis-nz { left: 86%; top: 14%; } /* upper-right = -Z */
457+
458+
.pi-ipf-caption {
459+
font-family: Lato, sans-serif;
460+
font-size: .7rem;
461+
color: var(--pi-slate-90);
462+
text-align: center;
463+
line-height: 1.25;
464+
max-width: 100%;
465+
word-wrap: break-word;
466+
}
467+
468+
@media (max-width: 768px) {
469+
.pi-ipf-legend-frame { max-width: 170px; }
470+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""
2+
Color-key (legend) widgets for the peak-indexing visualization tabs.
3+
4+
Provides reference images for orientation color modes that produce
5+
per-point RGB values without a Plotly colorbar:
6+
7+
* ``cubic_ipf`` -> standard cubic IPF stereographic triangle with
8+
001 (red) / 101 (green) / 111 (blue) corners.
9+
* ``pole_hsv`` (and pole-figure ``hsv_position``) -> circular HSV
10+
color wheel.
11+
12+
The pixel arrays are produced once at import time by
13+
:func:`laue_portal.analysis.coloring.make_cubic_ipf_triangle` and
14+
:func:`laue_portal.analysis.coloring.make_color_hexagon`, then encoded
15+
as base64 PNG data URIs that can be served directly via ``html.Img``.
16+
17+
The corner labels (001 / 101 / 111) and the dynamic caption
18+
("Crystal direction \u2225 sample normal", ...) are rendered as HTML
19+
elements layered around the image so they remain styleable via CSS
20+
without requiring PIL fonts.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import base64
26+
import io
27+
from typing import Optional
28+
29+
from dash import html
30+
from PIL import Image
31+
32+
from laue_portal.analysis.coloring import (
33+
make_color_hexagon,
34+
make_cubic_ipf_triangle,
35+
)
36+
37+
# ---------------------------------------------------------------------------
38+
# Pre-rendered color-key images (computed once at import)
39+
# ---------------------------------------------------------------------------
40+
41+
42+
def _rgba_array_to_data_uri(rgba: "np.ndarray") -> str: # noqa: F821
43+
"""Encode an (H, W, 4) uint8 RGBA array as a base64 PNG data URI."""
44+
img = Image.fromarray(rgba, mode="RGBA")
45+
buf = io.BytesIO()
46+
img.save(buf, format="PNG", optimize=True)
47+
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
48+
return f"data:image/png;base64,{encoded}"
49+
50+
51+
# Generated lazily on first access so module import stays cheap; cached
52+
# afterwards. Resolutions chosen to render crisply in the ~200 px sidebar
53+
# slot (devices with 2x DPR render at 400 px).
54+
_IPF_TRIANGLE_RES = 256
55+
_HSV_HEXAGON_RES = 256
56+
57+
_ipf_triangle_uri: Optional[str] = None
58+
_hsv_hexagon_uri: Optional[str] = None
59+
60+
61+
def _get_ipf_triangle_uri() -> str:
62+
global _ipf_triangle_uri
63+
if _ipf_triangle_uri is None:
64+
_ipf_triangle_uri = _rgba_array_to_data_uri(make_cubic_ipf_triangle(resolution=_IPF_TRIANGLE_RES))
65+
return _ipf_triangle_uri
66+
67+
68+
def _get_hsv_hexagon_uri() -> str:
69+
global _hsv_hexagon_uri
70+
if _hsv_hexagon_uri is None:
71+
_hsv_hexagon_uri = _rgba_array_to_data_uri(make_color_hexagon(resolution=_HSV_HEXAGON_RES))
72+
return _hsv_hexagon_uri
73+
74+
75+
# ---------------------------------------------------------------------------
76+
# Caption helpers
77+
# ---------------------------------------------------------------------------
78+
79+
# Human-readable surface-direction labels matching the
80+
# ``orientation-surface-select`` / ``stereo-surface-select`` options.
81+
_SURFACE_LABELS = {
82+
"normal": "sample normal",
83+
"X": "sample X",
84+
"H": "sample H",
85+
"Y": "sample Y",
86+
"Z": "sample Z",
87+
}
88+
89+
90+
def _surface_label(surface: Optional[str]) -> str:
91+
if not surface:
92+
return "sample normal"
93+
return _SURFACE_LABELS.get(surface, f"sample {surface}")
94+
95+
96+
# ---------------------------------------------------------------------------
97+
# Public component builders
98+
# ---------------------------------------------------------------------------
99+
100+
101+
def ipf_triangle_legend(caption: Optional[str] = None) -> html.Div:
102+
"""
103+
Build the cubic IPF reference triangle widget.
104+
105+
Parameters
106+
----------
107+
caption
108+
Optional short text rendered below the triangle (e.g.
109+
``"Crystal direction \u2225 sample normal"``).
110+
"""
111+
img = html.Img(
112+
src=_get_ipf_triangle_uri(),
113+
alt="Cubic IPF standard triangle",
114+
className="pi-ipf-legend-img",
115+
draggable="false",
116+
)
117+
118+
# Corner label positions are tuned to the geometry of
119+
# ``make_cubic_ipf_triangle``: the triangle occupies the lower-left
120+
# half of the square, with vertices at the bottom-left (001), the
121+
# right edge midpoint-ish (101), and the top-right (111).
122+
corners = [
123+
html.Span("001", className="pi-ipf-corner-label pi-ipf-corner-001"),
124+
html.Span("101", className="pi-ipf-corner-label pi-ipf-corner-101"),
125+
html.Span("111", className="pi-ipf-corner-label pi-ipf-corner-111"),
126+
]
127+
128+
children = [
129+
html.Div([img, *corners], className="pi-ipf-legend-frame"),
130+
]
131+
if caption:
132+
children.append(html.Div(caption, className="pi-ipf-caption"))
133+
134+
return html.Div(children, className="pi-ipf-legend")
135+
136+
137+
def hsv_hexagon_legend(caption: Optional[str] = None) -> html.Div:
138+
"""
139+
Build the HSV color-wheel reference widget for ``pole_hsv`` mode.
140+
141+
Six axis labels are layered around the circle (none on the vertical
142+
poles): +X right, -X left, +Y top-left, -Y bottom-right, +Z
143+
bottom-left, -Z top-right. Positioning is done via CSS classes so
144+
the labels remain styleable.
145+
"""
146+
img = html.Img(
147+
src=_get_hsv_hexagon_uri(),
148+
alt="HSV color wheel",
149+
className="pi-ipf-legend-img pi-ipf-legend-img--circle",
150+
draggable="false",
151+
)
152+
axis_labels = [
153+
html.Span("+X", className="pi-hsv-axis-label pi-hsv-axis-px"),
154+
html.Span("\u2212X", className="pi-hsv-axis-label pi-hsv-axis-nx"),
155+
html.Span("+Y", className="pi-hsv-axis-label pi-hsv-axis-py"),
156+
html.Span("\u2212Y", className="pi-hsv-axis-label pi-hsv-axis-ny"),
157+
html.Span("+Z", className="pi-hsv-axis-label pi-hsv-axis-pz"),
158+
html.Span("\u2212Z", className="pi-hsv-axis-label pi-hsv-axis-nz"),
159+
]
160+
children = [html.Div([img, *axis_labels], className="pi-ipf-legend-frame")]
161+
if caption:
162+
children.append(html.Div(caption, className="pi-ipf-caption"))
163+
return html.Div(children, className="pi-ipf-legend")
164+
165+
166+
def _empty_key(text: str = "No reference key for this color mode.") -> html.Div:
167+
return html.Div(
168+
html.Small(text, className="text-muted"),
169+
className="pi-ipf-legend pi-ipf-legend--empty",
170+
)
171+
172+
173+
# ---------------------------------------------------------------------------
174+
# Dispatchers used by page callbacks
175+
# ---------------------------------------------------------------------------
176+
177+
178+
def orientation_color_key(color_mode: Optional[str], surface: Optional[str]) -> html.Div:
179+
"""
180+
Return the appropriate color-key widget for the *Orientation* tab.
181+
182+
Parameters
183+
----------
184+
color_mode
185+
Value of ``orientation-color-select`` (e.g. ``"cubic_ipf"``).
186+
surface
187+
Value of ``orientation-surface-select`` (e.g. ``"normal"``).
188+
"""
189+
surf = _surface_label(surface)
190+
191+
if color_mode == "cubic_ipf":
192+
return ipf_triangle_legend(
193+
caption=f"Crystal direction \u2225 {surf}",
194+
)
195+
if color_mode == "pole_hsv":
196+
return hsv_hexagon_legend(
197+
caption=f"{surf.capitalize()} direction in stereographic HSV",
198+
)
199+
return _empty_key()
200+
201+
202+
def stereo_color_key(color_mode: Optional[str]) -> html.Div:
203+
"""
204+
Return the appropriate color-key widget for the *Pole Figure* tab.
205+
206+
The pole-figure tab has its own surface selector but the legend on
207+
this tab represents the cubic fundamental zone itself, so the caption
208+
stays static.
209+
"""
210+
if color_mode == "ipf":
211+
return ipf_triangle_legend(caption="Cubic IPF (m\u20133m)")
212+
if color_mode == "hsv_position":
213+
return hsv_hexagon_legend(caption="Stereographic position (HSV)")
214+
return _empty_key()

laue_portal/pages/peakindexing.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
import laue_portal.database.db_schema as db_schema
1313
import laue_portal.database.session_utils as session_utils
1414
from laue_portal.components.peakindex_form import peakindex_form, set_peakindex_form_props
15+
from laue_portal.components.visualization.ipf_legend import (
16+
orientation_color_key,
17+
stereo_color_key,
18+
)
1519
from laue_portal.config import DEFAULT_VARIABLES
1620
from laue_portal.database.db_utils import get_catalog_data, remove_root_path_prefix
1721

@@ -121,6 +125,16 @@ def _viz_graph_with_loading(graph, target_id, text="Updating\u2026"):
121125
),
122126
],
123127
),
128+
html.Div(
129+
className="pi-viz-sidebar-section",
130+
children=[
131+
_viz_sidebar_head("Color Key", "bi bi-triangle"),
132+
html.Div(
133+
id="orientation-color-key",
134+
children=orientation_color_key("cubic_ipf", "normal"),
135+
),
136+
],
137+
),
124138
html.Div(
125139
className="pi-viz-sidebar-section",
126140
children=[
@@ -305,6 +319,16 @@ def _viz_graph_with_loading(graph, target_id, text="Updating\u2026"):
305319
),
306320
],
307321
),
322+
html.Div(
323+
className="pi-viz-sidebar-section",
324+
children=[
325+
_viz_sidebar_head("Color Key", "bi bi-triangle"),
326+
html.Div(
327+
id="stereo-color-key",
328+
children=stereo_color_key("hsv_position"),
329+
),
330+
],
331+
),
308332
html.Div(
309333
className="pi-viz-sidebar-section",
310334
children=[
@@ -1313,6 +1337,30 @@ def handle_pole_selection(selected_data, xml_path, hkl_str):
13131337
return selected, summary
13141338

13151339

1340+
# ---------------------------------------------------------------------------
1341+
# Callbacks: color-key (IPF triangle / HSV hexagon) sidebar widgets
1342+
# ---------------------------------------------------------------------------
1343+
1344+
1345+
@callback(
1346+
Output("orientation-color-key", "children"),
1347+
Input("orientation-color-select", "value"),
1348+
Input("orientation-surface-select", "value"),
1349+
)
1350+
def update_orientation_color_key(color_mode, surface):
1351+
"""Refresh the IPF/HSV reference legend when mode or surface changes."""
1352+
return orientation_color_key(color_mode, surface)
1353+
1354+
1355+
@callback(
1356+
Output("stereo-color-key", "children"),
1357+
Input("stereo-color-select", "value"),
1358+
)
1359+
def update_stereo_color_key(color_mode):
1360+
"""Refresh the pole-figure reference legend when scheme changes."""
1361+
return stereo_color_key(color_mode)
1362+
1363+
13161364
# ---------------------------------------------------------------------------
13171365
# Helpers
13181366
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)