|
1 | 1 | """SVG taste card generator. |
2 | 2 |
|
3 | | -Produces a 600x400 SVG summarising a user's taste fingerprint. |
| 3 | +Produces a 1200x630 (social-share aspect) SVG summarising a user's taste |
| 4 | +fingerprint: an obscurity scale, ranked genre/label bars, and a drift sparkline. |
4 | 5 | All user-supplied strings are escaped via ``html.escape`` to prevent XSS. |
5 | 6 | """ |
6 | 7 |
|
|
9 | 10 | from api.models import TasteDriftYear |
10 | 11 |
|
11 | 12 |
|
| 13 | +# Card geometry |
| 14 | +_W = 1200 |
| 15 | +_H = 630 |
| 16 | + |
| 17 | +# Palette (purple/violet on deep navy) |
| 18 | +_BG_TOP = "#1c1c2e" |
| 19 | +_BG_BOTTOM = "#101019" |
| 20 | +_HDR_FROM = "#7c3aed" |
| 21 | +_HDR_TO = "#a855f7" |
| 22 | +_ACCENT_FROM = "#6c5ce7" |
| 23 | +_ACCENT_TO = "#a855f7" |
| 24 | +_TEXT = "#f5f5fa" |
| 25 | +_MUTED = "#8b8ba7" |
| 26 | +_FAINT = "#6b6b85" |
| 27 | + |
| 28 | +# Obscurity tiers (mainstream -> obscure). Boundary is inclusive on the upper |
| 29 | +# tier (``score >= threshold``), matching the project's tier convention. |
| 30 | +# Keep in sync with the Explore strip (explore/static/js/user-panes.js). |
| 31 | +_OBSCURITY_TIERS = [ |
| 32 | + (0.75, "Deep Cuts", "#f43f5e"), |
| 33 | + (0.50, "Obscure", "#a855f7"), |
| 34 | + (0.25, "Eclectic", "#818cf8"), |
| 35 | + (0.0, "Mainstream", "#38bdf8"), |
| 36 | +] |
| 37 | + |
| 38 | + |
| 39 | +def _esc(value: str) -> str: |
| 40 | + return html.escape(value) |
| 41 | + |
| 42 | + |
| 43 | +def _obscurity_tier(score: float) -> tuple[str, str]: |
| 44 | + """Return the (name, color) tier for an obscurity score in [0, 1].""" |
| 45 | + for threshold, name, color in _OBSCURITY_TIERS: |
| 46 | + if score >= threshold: |
| 47 | + return name, color |
| 48 | + return _OBSCURITY_TIERS[-1][1], _OBSCURITY_TIERS[-1][2] |
| 49 | + |
| 50 | + |
| 51 | +def _obscurity_scale(score: float, x: int, y: int, w: int) -> str: |
| 52 | + """A mainstream->obscure gradient track with a glowing marker at ``score``.""" |
| 53 | + s = max(0.0, min(1.0, score)) |
| 54 | + name, color = _obscurity_tier(s) |
| 55 | + pct = f"{score:.0%}" |
| 56 | + mx = x + s * w |
| 57 | + track_y = y + 15 |
| 58 | + h = 12 |
| 59 | + dot_cy = track_y + h / 2 |
| 60 | + return "\n ".join( |
| 61 | + [ |
| 62 | + f'<text x="{x}" y="{y}" font-size="16" font-weight="bold" fill="{_MUTED}" letter-spacing="2">OBSCURITY</text>', |
| 63 | + f'<text x="{x + w}" y="{y}" font-size="18" font-weight="bold" fill="{color}" text-anchor="end">{_esc(name)} · {pct}</text>', |
| 64 | + f'<rect x="{x}" y="{track_y}" width="{w}" height="{h}" rx="6" fill="url(#obscGrad)"/>', |
| 65 | + f'<circle cx="{mx:.1f}" cy="{dot_cy:.1f}" r="16" fill="{color}" opacity="0.35"/>', |
| 66 | + f'<circle cx="{mx:.1f}" cy="{dot_cy:.1f}" r="9" fill="#fff" stroke="{color}" stroke-width="3"/>', |
| 67 | + f'<text x="{x}" y="{track_y + h + 22}" font-size="13" fill="{_FAINT}">Mainstream</text>', |
| 68 | + f'<text x="{x + w}" y="{track_y + h + 22}" font-size="13" fill="{_FAINT}" text-anchor="end">Obscure</text>', |
| 69 | + ] |
| 70 | + ) |
| 71 | + |
| 72 | + |
| 73 | +def _ranked_bars(items: list[tuple[str, int]], x0: int, y_start: int, col_w: int, row_h: int) -> str: |
| 74 | + """A vertical list of ranked horizontal bars (length proportional to count).""" |
| 75 | + if not items: |
| 76 | + return f'<text x="{x0}" y="{y_start + 4}" font-size="17" fill="{_FAINT}">No data yet</text>' |
| 77 | + max_count = max((c for _, c in items), default=0) or 1 |
| 78 | + parts: list[str] = [] |
| 79 | + for i, (name, count) in enumerate(items[:5]): |
| 80 | + y = y_start + i * row_h |
| 81 | + bar_w = max(6, round(col_w * (count / max_count))) |
| 82 | + parts.append(f'<text x="{x0}" y="{y}" font-size="21" fill="{_TEXT}">{_esc(name)}</text>') |
| 83 | + parts.append(f'<text x="{x0 + col_w}" y="{y}" font-size="15" fill="{_MUTED}" text-anchor="end">{count:,}</text>') |
| 84 | + parts.append(f'<rect x="{x0}" y="{y + 11}" width="{col_w}" height="9" rx="4.5" fill="#ffffff14"/>') |
| 85 | + parts.append(f'<rect x="{x0}" y="{y + 11}" width="{bar_w}" height="9" rx="4.5" fill="url(#barGrad)"/>') |
| 86 | + return "\n ".join(parts) |
| 87 | + |
| 88 | + |
| 89 | +def _drift_sparkline(drift: list[TasteDriftYear], x: int, y: int, w: int, h: int) -> str: |
| 90 | + """A small line chart of additions-per-year, with year labels and genre tooltips.""" |
| 91 | + pts = drift[-8:] |
| 92 | + if not pts: |
| 93 | + return f'<text x="{x}" y="{y + h // 2}" font-size="17" fill="{_FAINT}">No drift data yet</text>' |
| 94 | + max_count = max((d.count for d in pts), default=0) or 1 |
| 95 | + n = len(pts) |
| 96 | + step = w / (n - 1) if n > 1 else 0.0 |
| 97 | + coords: list[tuple[float, float, TasteDriftYear]] = [] |
| 98 | + for i, d in enumerate(pts): |
| 99 | + px = x + i * step if n > 1 else x + w / 2 |
| 100 | + py = y + h - (d.count / max_count) * h |
| 101 | + coords.append((px, py, d)) |
| 102 | + |
| 103 | + parts: list[str] = [] |
| 104 | + if n > 1: |
| 105 | + line = " ".join(f"{px:.1f},{py:.1f}" for px, py, _ in coords) |
| 106 | + parts.append(f'<polyline points="{line}" fill="none" stroke="url(#barGrad)" stroke-width="3" stroke-linejoin="round"/>') |
| 107 | + for idx, (px, py, d) in enumerate(coords): |
| 108 | + last = idx == n - 1 |
| 109 | + parts.append( |
| 110 | + f'<circle cx="{px:.1f}" cy="{py:.1f}" r="{5 if last else 4}" fill="{_ACCENT_TO if last else _ACCENT_FROM}">' |
| 111 | + f"<title>{_esc(d.year)}: {_esc(d.top_genre)} ({d.count:,})</title></circle>" |
| 112 | + ) |
| 113 | + parts.append(f'<text x="{px:.1f}" y="{y + h + 20}" font-size="13" fill="{_MUTED}" text-anchor="middle">{_esc(d.year)}</text>') |
| 114 | + # Caption: the most recent year's top genre. |
| 115 | + parts.append(f'<text x="{x}" y="{y - 14}" font-size="15" fill="{_FAINT}">Now: {_esc(coords[-1][2].top_genre)}</text>') |
| 116 | + return "\n ".join(parts) |
| 117 | + |
| 118 | + |
12 | 119 | def render_taste_card( |
13 | 120 | *, |
14 | 121 | peak_decade: int | None, |
15 | 122 | obscurity_score: float, |
16 | | - top_genres: list[str], |
17 | | - top_labels: list[str], |
| 123 | + top_genres: list[tuple[str, int]], |
| 124 | + top_labels: list[tuple[str, int]], |
18 | 125 | drift: list[TasteDriftYear], |
19 | 126 | ) -> str: |
20 | | - """Return a 600x400 SVG string summarising the user's taste fingerprint.""" |
21 | | - decade_text = f"{html.escape(str(peak_decade))}s" if peak_decade is not None else "N/A" |
22 | | - bar_width = max(4, min(200, int(obscurity_score * 200))) |
23 | | - |
24 | | - genre_lines = "" |
25 | | - for i, genre in enumerate(top_genres[:5]): |
26 | | - y = 155 + i * 20 |
27 | | - genre_lines += f' <text x="30" y="{y}" font-size="13" fill="#ccc">{html.escape(genre)}</text>\n' |
28 | | - |
29 | | - label_lines = "" |
30 | | - for i, label in enumerate(top_labels[:5]): |
31 | | - y = 155 + i * 20 |
32 | | - label_lines += f' <text x="320" y="{y}" font-size="13" fill="#ccc">{html.escape(label)}</text>\n' |
33 | | - |
34 | | - drift_lines = "" |
35 | | - for i, d in enumerate(drift[-5:]): |
36 | | - y = 320 + i * 18 |
37 | | - drift_lines += f' <text x="30" y="{y}" font-size="12" fill="#aaa">{html.escape(d.year)}: {html.escape(d.top_genre)}</text>\n' |
38 | | - |
39 | | - svg = f"""\ |
40 | | -<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400" viewBox="0 0 600 400"> |
41 | | - <rect width="600" height="400" rx="12" fill="#1a1a2e"/> |
42 | | - <text x="300" y="35" font-size="18" font-weight="bold" fill="#e0e0e0" text-anchor="middle">Taste Fingerprint</text> |
43 | | -
|
44 | | - <!-- Peak Decade --> |
45 | | - <text x="30" y="70" font-size="14" fill="#888">Peak Decade</text> |
46 | | - <text x="30" y="92" font-size="22" font-weight="bold" fill="#fff">{decade_text}</text> |
47 | | -
|
48 | | - <!-- Obscurity --> |
49 | | - <text x="320" y="70" font-size="14" fill="#888">Obscurity</text> |
50 | | - <rect x="320" y="78" width="200" height="16" rx="4" fill="#333"/> |
51 | | - <rect x="320" y="78" width="{bar_width}" height="16" rx="4" fill="#6c5ce7"/> |
52 | | - <text x="530" y="91" font-size="12" fill="#ccc">{obscurity_score:.0%}</text> |
53 | | -
|
54 | | - <!-- Top Genres --> |
55 | | - <text x="30" y="130" font-size="14" fill="#888">Top Genres</text> |
56 | | -{genre_lines} |
57 | | - <!-- Top Labels --> |
58 | | - <text x="320" y="130" font-size="14" fill="#888">Top Labels</text> |
59 | | -{label_lines} |
60 | | - <!-- Drift --> |
61 | | - <text x="30" y="300" font-size="14" fill="#888">Taste Drift</text> |
62 | | -{drift_lines}</svg>""" |
63 | | - |
64 | | - return svg |
| 127 | + """Return a 1200x630 SVG string summarising the user's taste fingerprint.""" |
| 128 | + decade_text = f"{_esc(str(peak_decade))}s" if peak_decade is not None else "N/A" |
| 129 | + |
| 130 | + obscurity = _obscurity_scale(obscurity_score, x=50, y=190, w=1100) |
| 131 | + genre_bars = _ranked_bars(top_genres, x0=50, y_start=330, col_w=480, row_h=48) |
| 132 | + label_bars = _ranked_bars(top_labels, x0=640, y_start=330, col_w=480, row_h=48) |
| 133 | + sparkline = _drift_sparkline(drift, x=360, y=584, w=560, h=26) |
| 134 | + |
| 135 | + return f"""\ |
| 136 | +<svg xmlns="http://www.w3.org/2000/svg" width="{_W}" height="{_H}" viewBox="0 0 {_W} {_H}"> |
| 137 | + <defs> |
| 138 | + <linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1"> |
| 139 | + <stop offset="0" stop-color="{_BG_TOP}"/> |
| 140 | + <stop offset="1" stop-color="{_BG_BOTTOM}"/> |
| 141 | + </linearGradient> |
| 142 | + <linearGradient id="hdrGrad" x1="0" y1="0" x2="1" y2="0"> |
| 143 | + <stop offset="0" stop-color="{_HDR_FROM}"/> |
| 144 | + <stop offset="1" stop-color="{_HDR_TO}"/> |
| 145 | + </linearGradient> |
| 146 | + <linearGradient id="barGrad" x1="0" y1="0" x2="1" y2="0"> |
| 147 | + <stop offset="0" stop-color="{_ACCENT_FROM}"/> |
| 148 | + <stop offset="1" stop-color="{_ACCENT_TO}"/> |
| 149 | + </linearGradient> |
| 150 | + <linearGradient id="obscGrad" x1="0" y1="0" x2="1" y2="0"> |
| 151 | + <stop offset="0" stop-color="#38bdf8"/> |
| 152 | + <stop offset="0.33" stop-color="#818cf8"/> |
| 153 | + <stop offset="0.66" stop-color="#a855f7"/> |
| 154 | + <stop offset="1" stop-color="#f43f5e"/> |
| 155 | + </linearGradient> |
| 156 | + <clipPath id="card"><rect x="0" y="0" width="{_W}" height="{_H}" rx="28"/></clipPath> |
| 157 | + </defs> |
| 158 | +
|
| 159 | + <g clip-path="url(#card)"> |
| 160 | + <rect x="0" y="0" width="{_W}" height="{_H}" fill="url(#bgGrad)"/> |
| 161 | + <rect x="0" y="0" width="{_W}" height="150" fill="url(#hdrGrad)"/> |
| 162 | + </g> |
| 163 | +
|
| 164 | + <!-- Header --> |
| 165 | + <text x="50" y="70" font-size="40" font-weight="bold" fill="#ffffff" letter-spacing="1">TASTE FINGERPRINT</text> |
| 166 | + <text x="50" y="108" font-size="20" fill="#ffffffcc">Peak decade <tspan font-weight="bold" fill="#ffffff">{decade_text}</tspan></text> |
| 167 | +
|
| 168 | + <!-- Obscurity scale --> |
| 169 | + {obscurity} |
| 170 | +
|
| 171 | + <!-- Section headers --> |
| 172 | + <text x="50" y="300" font-size="18" font-weight="bold" fill="{_MUTED}" letter-spacing="2">TOP GENRES</text> |
| 173 | + <text x="640" y="300" font-size="18" font-weight="bold" fill="{_MUTED}" letter-spacing="2">TOP LABELS</text> |
| 174 | +
|
| 175 | + <!-- Ranked bars --> |
| 176 | + {genre_bars} |
| 177 | + {label_bars} |
| 178 | +
|
| 179 | + <!-- Footer band: drift sparkline + branding --> |
| 180 | + <line x1="50" y1="556" x2="1150" y2="556" stroke="#ffffff14" stroke-width="1"/> |
| 181 | + <text x="50" y="586" font-size="14" font-weight="bold" fill="{_MUTED}" letter-spacing="2">TASTE DRIFT</text> |
| 182 | + {sparkline} |
| 183 | + <text x="1150" y="604" font-size="20" font-weight="bold" fill="url(#barGrad)" text-anchor="end">discogsography</text> |
| 184 | +</svg>""" |
0 commit comments