diff --git a/packages/tourmaline/src/lib/share/festival-svg.ts b/packages/tourmaline/src/lib/share/festival-svg.ts new file mode 100644 index 0000000..48e5c9c --- /dev/null +++ b/packages/tourmaline/src/lib/share/festival-svg.ts @@ -0,0 +1,125 @@ +/** + * Generates an Instafest-style festival lineup poster from a listener's top + * artists — headliners in large type at the top, smaller acts in rows below, + * font size falling off by rank tier. Same theme/escaping approach as the + * other share cards. + */ +import { BG, BORDER, ACCENT, TEXT, DIM, FONT_FACE_CSS, esc } from "./theme"; + +export interface FestivalCardData { + displayName?: string; + rangeLabel?: string; + artists: Array<{ name: string; count: number }>; +} + +const WIDTH = 600; +const PAD = 32; + +/** Rank tiers: [count of artists in this tier, font size, letter spacing]. */ +const TIERS: Array<{ count: number; size: number; weight: number; gap: number }> = [ + { count: 3, size: 30, weight: 800, gap: 8 }, + { count: 5, size: 19, weight: 700, gap: 6 }, + { count: 6, size: 14, weight: 600, gap: 5 }, + { count: 8, size: 11, weight: 500, gap: 4 }, +]; + +/** Wrap a list of names into lines that roughly fit `maxWidth`, using a crude per-character estimate. */ +function wrapNames(names: string[], fontSize: number, maxWidth: number, separator = " • "): string[] { + const charWidth = fontSize * 0.58; + const sepWidth = separator.length * charWidth; + const lines: string[] = []; + let current: string[] = []; + let currentWidth = 0; + + for (const name of names) { + const nameWidth = name.length * charWidth; + const addedWidth = current.length > 0 ? sepWidth + nameWidth : nameWidth; + if (currentWidth + addedWidth > maxWidth && current.length > 0) { + lines.push(current.join(separator)); + current = [name]; + currentWidth = nameWidth; + } else { + current.push(name); + currentWidth += addedWidth; + } + } + if (current.length > 0) lines.push(current.join(separator)); + return lines; +} + +export function renderFestivalSvg(card: FestivalCardData): string { + const artists = (Array.isArray(card.artists) ? card.artists : []) + .filter((a) => a && typeof a === "object" && typeof a.name === "string") + .slice(0, TIERS.reduce((sum, t) => sum + t.count, 0)); + + // ── Layout ────────────────────────────────────────────────────────── + let y = 0; + y += 40; // top padding + const titleY = y + 28; + y += 40; + const subtitleY = y + 12; + y += 24; + + y += 12; + const dividerTopY = y; + y += 28; + + let cursor = 0; + const tierRows: Array<{ lines: string[]; size: number; weight: number; startY: number }> = []; + for (const tier of TIERS) { + const names = artists.slice(cursor, cursor + tier.count).map((a) => a.name); + cursor += tier.count; + if (names.length === 0) continue; + const lines = wrapNames(names, tier.size, WIDTH - PAD * 2); + const startY = y; + tierRows.push({ lines, size: tier.size, weight: tier.weight, startY }); + for (const _line of lines) { + y += tier.size * 1.5; + } + y += tier.gap * 2; + } + + y += 12; + const dividerBottomY = y; + y += 28; + + const footerY = y + 10; + y += 30; + + const HEIGHT = Math.ceil(y); + + const tiersSvg = tierRows + .map((row) => + row.lines + .map((line, i) => { + const ty = row.startY + i * row.size * 1.5 + row.size; + return `${esc(line)}`; + }) + .join(""), + ) + .join(""); + + return ` + + + + + + + + + + + + + TOURMALINE FEST + ${esc((card.rangeLabel ?? "ALL TIME").toUpperCase())} · CURATED BY ${esc((card.displayName ?? "A LISTENER").toUpperCase())} + + + + ${tiersSvg} + + + TOURMALINE.CROFT.CLICK +`; +} diff --git a/packages/tourmaline/src/lib/share/post.ts b/packages/tourmaline/src/lib/share/post.ts index 964f07f..24558a1 100644 --- a/packages/tourmaline/src/lib/share/post.ts +++ b/packages/tourmaline/src/lib/share/post.ts @@ -12,6 +12,8 @@ import type { PersonalityCardData } from "./personality-svg"; import { renderPersonalitySvg } from "./personality-svg"; import type { ReceiptCardData } from "./receipt-svg"; import { renderReceiptSvg } from "./receipt-svg"; +import type { FestivalCardData } from "./festival-svg"; +import { renderFestivalSvg } from "./festival-svg"; import { svgToPng } from "./svg-to-png"; export interface ShareResult { @@ -35,9 +37,12 @@ export async function postCardImage( const pngBytes = await svgToPng(opts.svg); // Parse SVG dimensions for aspect ratio - const viewBoxMatch = opts.svg.match(/viewBox="0 0 (\d+) (\d+)"/); - const svgW = viewBoxMatch ? parseInt(viewBoxMatch[1], 10) : 600; - const svgH = viewBoxMatch ? parseInt(viewBoxMatch[2], 10) : 620; + // Card heights can be non-integer (accumulated from fractional line-height + // math) — match decimals too, or a card with a fractional viewBox silently + // falls back to the wrong aspect ratio here. + const viewBoxMatch = opts.svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/); + const svgW = viewBoxMatch ? Math.round(parseFloat(viewBoxMatch[1])) : 600; + const svgH = viewBoxMatch ? Math.round(parseFloat(viewBoxMatch[2])) : 620; // 2. Upload the image blob const { data: blobData } = await agent.uploadBlob(pngBytes, { @@ -153,3 +158,22 @@ export async function shareReceipt(agent: Agent, card: ReceiptCardData): Promise card.totalScrobbles != null ? { scrobblesAnalyzed: card.totalScrobbles } : {}, }); } + +export async function shareFestival(agent: Agent, card: FestivalCardData): Promise { + const svg = renderFestivalSvg(card); + + const artists = (Array.isArray(card.artists) ? card.artists : []).slice(0, 10); + const alt = [ + `Festival-style lineup poster for ${card.displayName ?? "this listener"} (${card.rangeLabel ?? "all time"}).`, + artists.length ? `Lineup, headliners first: ${artists.map((a) => a.name).join(", ")}.` : "", + ] + .filter(Boolean) + .join(" "); + + return postCardImage(agent, { + svg, + alt, + postText: `My listening lineup (${card.rangeLabel ?? "all time"}) 🎪\n\nvia tourmaline by @ewancroft.uk`, + toolkitExtra: {}, + }); +} diff --git a/packages/tourmaline/src/lib/share/registry.ts b/packages/tourmaline/src/lib/share/registry.ts index 6d235a6..8991fc2 100644 --- a/packages/tourmaline/src/lib/share/registry.ts +++ b/packages/tourmaline/src/lib/share/registry.ts @@ -12,9 +12,11 @@ import type { PersonalityCardData } from "./personality-svg"; import { renderPersonalitySvg } from "./personality-svg"; import type { ReceiptCardData } from "./receipt-svg"; import { renderReceiptSvg } from "./receipt-svg"; -import { sharePersonality, shareReceipt, type ShareResult } from "./post"; +import type { FestivalCardData } from "./festival-svg"; +import { renderFestivalSvg } from "./festival-svg"; +import { sharePersonality, shareReceipt, shareFestival, type ShareResult } from "./post"; -export type ShareCardType = "personality" | "receipt"; +export type ShareCardType = "personality" | "receipt" | "festival"; /** sessionStorage envelope written by a card's "Share" button, read by /share. */ export interface ShareEnvelope { @@ -31,6 +33,7 @@ interface CardHandler { const handlers: Record> = { personality: { render: renderPersonalitySvg, share: sharePersonality }, receipt: { render: renderReceiptSvg, share: shareReceipt }, + festival: { render: renderFestivalSvg, share: shareFestival }, }; export function renderCard(envelope: ShareEnvelope): string { @@ -46,4 +49,4 @@ export function writeShareEnvelope(type: ShareCardType, data: unknown): void { sessionStorage.setItem("tourmaline:share", JSON.stringify({ type, data })); } -export type { PersonalityCardData, ReceiptCardData }; +export type { PersonalityCardData, ReceiptCardData, FestivalCardData }; diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index b0f48f9..1e40210 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -1,7 +1,7 @@