File size: 10,061 Bytes
d81f51d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""Project visual system. Single source of truth for matplotlib + Plotly + graphviz.

**Linear / Vercel anchor (dark Β· clinical Β· projection-sized).**
Anchor decided 2026-05-09 in design.md Β§1. Previous Stripe Press tokens are
preserved as ``src/plot_style_stripe.py.bak`` and can be restored by
``cp src/plot_style_stripe.py.bak src/plot_style.py``.

Public surface unchanged β€” every name imported by the rest of the project is
still exported with the same identifier, only the *values* changed:

    BG, SURFACE, RULE, INK, INK_MUTED, INK_FAINT, ACCENT
    CLUSTER_FILLS, CLUSTER_MARKERS, CENTROID_COLOR, INK_RAMP
    FONT_DISPLAY, FONT_BODY, FONT_MONO
    apply_style, use_mono_ticks, plotly_template, GRAPHVIZ_DEFAULTS

The discipline that defines this anchor:
    1. Single accent (``ACCENT``) reserved for SIGNIFICANT outcomes only β€”
       p ≀ 0.05 cells, the winning configuration row, the live-predict
       assigned cluster, active selections. Everything else is grayscale.
    2. Type sized for ~3-5 m projection: tick fonts β‰₯ 14px, body β‰₯ 16px,
       headlines 28-40px. No 12px text anywhere.
    3. Backgrounds are warm-near-black (#08090A), not pure #000 β€” pure
       black on a projector reads as a void; a hint of warmth keeps the
       surface feeling like a real product.
"""

from __future__ import annotations

import warnings
from pathlib import Path

import matplotlib as mpl
import matplotlib.font_manager as fm
import plotly.graph_objects as go


# === Linear / Vercel dark tokens (locked 2026-05-09) ===================
BG = "#08090A"             # canvas β€” near-black, slightly warm
SURFACE = "#101113"        # cards, sidebar
SURFACE_RAISED = "#16171A" # hover / active row
RULE = "#1F2023"           # dividers, axis lines, table borders
INK = "#F4F4F5"            # primary text
INK_MUTED = "#A1A1AA"      # secondary text, axis labels
INK_FAINT = "#71717A"      # tertiary text, captions
ACCENT = "#22D3EE"         # single accent β€” cyan, used only for SIGNIFICANCE
ACCENT_DIM = "#0E7490"     # accent backgrounds, hover states for accent elements
POSITIVE = "#34D399"       # tertiary signal β€” gain / improvement (sparingly)
NEGATIVE = "#F87171"       # tertiary signal β€” loss / regression  (sparingly)


# === Cluster mapping ===================================================
# On a dark canvas the contrast logic flips: the "primary" cluster gets the
# brightest fill (INK β‰ˆ white), with monotone steps down. The accent (cyan)
# is reserved for centroids and active-selection halo, NOT for cluster
# membership β€” so a single cyan hit on a chart always means "this is the one
# you should look at" (winning config, predicted assignment, p ≀ 0.05).
CLUSTER_FILLS = {
    0: INK,           # primary cluster (post-canonical-remap)
    1: INK_MUTED,     # secondary
    2: INK_FAINT,     # tertiary
    -1: "#3F3F46",    # noise (HDBSCAN)
}
CLUSTER_MARKERS = {0: "o", 1: "s", 2: "^", -1: "x"}
CENTROID_COLOR = ACCENT
INK_RAMP = [INK, INK_MUTED, INK_FAINT, ACCENT_DIM]


# === Font stacks =======================================================
# Inter throughout β€” Linear-faithful, projection-legible. JetBrains Mono
# for tabular numerics so silhouette / ARI / p-values align column-wise.
FONT_DISPLAY = ["Inter", "system-ui", "-apple-system", "Segoe UI", "Helvetica Neue", "sans-serif"]
FONT_BODY = ["Inter", "system-ui", "-apple-system", "Segoe UI", "Helvetica Neue", "sans-serif"]
FONT_MONO = ["JetBrains Mono", "SF Mono", "Menlo", "Consolas", "monospace"]


# === Bundled-font registration =========================================
def _register_bundled_fonts() -> None:
    """Load bundled .ttf fonts so PNG output is deterministic across machines."""
    fonts_dir = Path(__file__).resolve().parent.parent / "assets" / "fonts"
    if not fonts_dir.exists():
        return
    for ttf in fonts_dir.glob("*.ttf"):
        try:
            fm.fontManager.addfont(str(ttf))
        except Exception:  # pragma: no cover
            pass


def _check_fonts() -> None:
    """Warn once if Inter / JetBrains Mono aren't available after registration."""
    available = {f.name for f in fm.fontManager.ttflist}
    required = {"Inter", "JetBrains Mono"}
    missing = required - available
    if missing:
        warnings.warn(
            f"Required fonts missing β€” figures will fall back: {missing}. "
            f"Install via assets/fonts/*.ttf or system font manager.",
            stacklevel=2,
        )


_register_bundled_fonts()
_check_fonts()


# === matplotlib rcParams ===============================================
def apply_style() -> None:
    """Apply project rcParams. Idempotent β€” call once per script entrypoint.

    NOTE on tabular figures: rcParams cannot enforce monospace digit alignment
    in matplotlib because the text renderer doesn't activate OpenType ``tnum``
    features. The discipline is :func:`use_mono_ticks` on any axes showing
    decimals β€” that's what actually delivers tabular alignment.
    """
    mpl.rcParams.update({
        # --- Type ramp (sized for 3-5 m projection) ---------------------
        "font.family":        "sans-serif",
        "font.sans-serif":    FONT_DISPLAY,
        "font.size":          14,
        "axes.titlesize":     18,
        "axes.titleweight":   600,
        "axes.labelsize":     14,
        "xtick.labelsize":    13,
        "ytick.labelsize":    13,
        "legend.fontsize":    13,
        "figure.titlesize":   22,
        "figure.titleweight": 600,

        # --- Backgrounds ------------------------------------------------
        "figure.facecolor":   BG,
        "axes.facecolor":     BG,
        "savefig.facecolor":  BG,
        "savefig.edgecolor":  "none",
        "savefig.bbox":       "tight",
        "savefig.dpi":        200,

        # --- Spines (explicit; do not inherit user matplotlibrc) --------
        "axes.spines.top":    False,
        "axes.spines.right":  False,
        "axes.spines.left":   True,
        "axes.spines.bottom": True,
        "axes.edgecolor":     RULE,
        "axes.linewidth":     0.8,
        "axes.labelcolor":    INK_MUTED,
        "axes.titlecolor":    INK,

        "xtick.color":        INK_MUTED,
        "ytick.color":        INK_MUTED,
        "xtick.direction":    "out",
        "ytick.direction":    "out",

        # --- Grid (subtle) ----------------------------------------------
        "axes.grid":          True,
        "axes.grid.axis":     "y",
        "grid.color":         RULE,
        "grid.linewidth":     0.5,

        # --- Color cycle + legend frame ---------------------------------
        "axes.prop_cycle":    mpl.cycler(color=INK_RAMP),
        "legend.frameon":     False,
        "text.color":         INK,
    })


def use_mono_ticks(ax) -> None:
    """Switch numeric tick labels to JetBrains Mono so decimals align."""
    for label in ax.get_xticklabels() + ax.get_yticklabels():
        label.set_family(FONT_MONO)


# === Plotly template ===================================================
def plotly_template() -> go.layout.Template:
    """Return a Plotly template matching the matplotlib system. Apply via:

        import plotly.io as pio
        pio.templates["int396"] = plotly_template()
        pio.templates.default = "int396"
    """
    sans = "Inter, system-ui, sans-serif"
    mono = "JetBrains Mono, SF Mono, Menlo, monospace"
    return go.layout.Template(
        layout=go.Layout(
            font=dict(family=sans, size=14, color=INK),
            title=dict(
                font=dict(family=sans, size=20, color=INK, weight=600),
                x=0.0,
                xanchor="left",
            ),
            paper_bgcolor=BG,
            plot_bgcolor=BG,
            colorway=INK_RAMP,
            xaxis=dict(
                gridcolor=RULE,
                gridwidth=0.5,
                linecolor=RULE,
                tickcolor=RULE,
                tickfont=dict(family=mono, size=13, color=INK_MUTED),
                title=dict(font=dict(family=sans, size=14, color=INK_MUTED)),
                zeroline=False,
            ),
            yaxis=dict(
                gridcolor=RULE,
                gridwidth=0.5,
                linecolor=RULE,
                tickcolor=RULE,
                tickfont=dict(family=mono, size=13, color=INK_MUTED),
                title=dict(font=dict(family=sans, size=14, color=INK_MUTED)),
                zeroline=False,
            ),
            legend=dict(
                bgcolor="rgba(0,0,0,0)",
                bordercolor="rgba(0,0,0,0)",
                font=dict(size=13, color=INK_MUTED),
            ),
            margin=dict(l=56, r=24, t=56, b=56),
            hoverlabel=dict(
                bgcolor=SURFACE_RAISED,
                bordercolor=RULE,
                font=dict(family=sans, size=13, color=INK),
            ),
        )
    )


# === Graphviz defaults =================================================
GRAPHVIZ_DEFAULTS = dict(
    graph_attr={
        "bgcolor":  BG,
        "fontname": "Inter",
        "fontsize": "13",
        "rankdir":  "LR",
        "nodesep":  "0.4",
        "ranksep":  "0.6",
        "pad":      "0.4",
        "fontcolor": INK,
    },
    node_attr={
        "shape":     "box",
        "style":     "filled,rounded",
        "fillcolor": SURFACE,
        "color":     RULE,
        "fontname":  "Inter",
        "fontsize":  "12",
        "fontcolor": INK,
        "penwidth":  "0.8",
        "margin":    "0.2,0.12",
    },
    edge_attr={
        "color":     INK_FAINT,
        "penwidth":  "0.7",
        "arrowsize": "0.6",
        "fontname":  "JetBrains Mono",
        "fontsize":  "10",
        "fontcolor": INK_MUTED,
    },
)


__all__ = [
    "BG", "SURFACE", "SURFACE_RAISED", "RULE",
    "INK", "INK_MUTED", "INK_FAINT", "ACCENT", "ACCENT_DIM",
    "POSITIVE", "NEGATIVE",
    "CLUSTER_FILLS", "CLUSTER_MARKERS", "CENTROID_COLOR", "INK_RAMP",
    "FONT_DISPLAY", "FONT_BODY", "FONT_MONO",
    "apply_style", "use_mono_ticks", "plotly_template",
    "GRAPHVIZ_DEFAULTS",
]