diff --git a/src/components/panels/SourcePanel.vue b/src/components/panels/SourcePanel.vue index 0abe2dc..c8f4ddb 100644 --- a/src/components/panels/SourcePanel.vue +++ b/src/components/panels/SourcePanel.vue @@ -458,12 +458,14 @@ onBeforeUnmount(() => { } .thumb-label { position: absolute; - left: 6px; - bottom: 5px; + left: 4px; + bottom: 4px; color: var(--fg); font-size: 11px; line-height: 1.2; - text-shadow: 0 1px 3px rgb(0 0 0 / 0.7); + padding: 2px 5px; + border-radius: 3px; + background: rgb(0 0 0 / 0.55); /* 底衬:压暗条,白字底下不再直接是图案 */ } /* Unsplash 结果网格:3 列 */ diff --git a/src/sources/geometry.ts b/src/sources/geometry.ts index 674ae78..5905663 100644 --- a/src/sources/geometry.ts +++ b/src/sources/geometry.ts @@ -18,6 +18,11 @@ import type { BackgroundAsset } from "../types"; * * 种子建议填标题:同一系列标题前缀相同 → 落进同一个 45° 色区, * 系列内不同篇再在色区内按全文 hash 微差几度,列表里自然成组又不至于一模一样。 + * 种子留空时每种底纹各有一个专属默认色相,四张缩略图一眼分得开。 + * + * 配色目标是「能压白字的背板」而不是装饰画:所有颜色都在线性空间里限过亮度—— + * 底色对纯白的 WCAG 对比度至少 ~7:1,最亮的强调色也 ≥ ~4.9:1, + * 饱和度压到低饱和、偏中性。 */ export type GeometryKind = "stripes" | "noise" | "grid" | "arcs"; @@ -51,6 +56,14 @@ export interface GeometryParams { toneB: RGB; } +/** 留空种子时每种底纹的默认色相:分散在色环上,缩略图可一眼区分。 */ +const EMPTY_HUE: Record = { + stripes: 215, // 蓝 + noise: 25, // 琥珀 + grid: 265, // 紫 + arcs: 350, // 玫红 +}; + /** 由种子文本派生一组确定性参数。纯函数。 */ export function deriveGeometryParams( seedText: string, @@ -58,22 +71,34 @@ export function deriveGeometryParams( width: number, height: number, ): GeometryParams { - const text = (seedText ?? "").trim() || "cover"; - const hFull = hashText(text); - const hPrefix = hashText(text.slice(0, 4)); + const text = (seedText ?? "").trim(); + const empty = text.length === 0; + // 空种子也必须有确定性:以 kind 本身作 hashKey,四种底纹天然不同源。 + const hashKey = empty ? `geometry:${kind}` : text; + const hFull = hashText(hashKey); + const hPrefix = hashText(hashKey.slice(0, 4)); const rand = mulberry32(hFull); const u: number[] = []; for (let i = 0; i < 16; i++) u.push(rand()); - // 色相分桶:前 4 个字符决定 45° 色区 → 同前缀同色区; - // 桶内再按全文 hash 偏 0~30°,同系列不同篇相邻成组。 - const hue = ((hPrefix >>> 0) % 8) * 45 + (((hFull >>> 0) % 3000) / 100); + // 色相:非空种子按「前 4 字符分桶 + 全文微调」,同系列相邻成组; + // 空种子直接取该 kind 的专属默认色相。 + const hue = empty + ? EMPTY_HUE[kind] + : ((hPrefix >>> 0) % 8) * 45 + (((hFull >>> 0) % 3000) / 100); const off = 46 + u[1] * 54; // 主色到强调色的色相偏移(46~100°,类比色相,不花) - const s = Math.min(0.44, 0.28 + u[0] * 0.16); // 低饱和:底是要被压白字的 - const base = hsl2rgb(hue, s, 0.27 + u[2] * 0.06); - const toneA = hsl2rgb(hue + off, Math.min(s + 0.06, 0.5), 0.52 + u[3] * 0.1); - const toneB = hsl2rgb(hue + off * 0.5, s * 0.92, 0.44 + u[4] * 0.06); + // 低饱和、偏中性(底要被压白字,高饱和会抢注意力) + const sBase = Math.min(0.18, 0.08 + u[0] * 0.1); + const sA = Math.min(0.3, 0.18 + u[3] * 0.12); + const sB = Math.min(0.24, 0.14 + u[4] * 0.1); + + // 线性空间限亮度,保证对白字的 WCAG 对比度: + // 底色 ≤ 0.10 相对亮度 → 对 #fff ≈ 7:1; + // 副强调 ≤ 0.135 → ≈ 5.7:1;主强调 ≤ 0.165 → ≈ 4.9:1(最亮也不破 4.5)。 + const base = clampLuminance(hsl2rgb(hue, sBase, 0.21 + u[2] * 0.05), 0.1); + const toneA = clampLuminance(hsl2rgb(hue + off, sA, 0.38 + u[5] * 0.06), 0.165); + const toneB = clampLuminance(hsl2rgb(hue + off * 0.5, sB, 0.29 + u[6] * 0.05), 0.135); return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB }; } @@ -167,7 +192,7 @@ function renderNoiseGradient(p: GeometryParams, out: Uint8ClampedArray): void { const { width: w, height: h, u } = p; const sx = u[8] < 0.5 ? 1 : -1; const sy = u[9] < 0.5 ? 1 : -1; - const amp = 4 + u[10] * 5; // 噪点幅度 + const amp = 3 + u[10] * 4; // 噪点幅度 // 对角投影 t 的取值区间(四个角的极值) let lo = 0; let hi = 0; @@ -386,6 +411,40 @@ function hsl2rgb(hueDeg: number, s: number, l: number): RGB { return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; } +/** sRGB 通道值(0-255) → 线性光 (0-1)。 */ +function linearize(v: number): number { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +/** 线性光 → sRGB 通道值。 */ +function toSrgb(lin: number): number { + const c = lin <= 0.0031308 ? lin * 12.92 : 1.055 * Math.pow(lin, 1 / 2.4) - 0.055; + const v = Math.round((c < 0 ? 0 : c > 1 ? 1 : c) * 255); + return v; +} + +/** WCAG 相对亮度(0-1)。 */ +function luminance(rgb: RGB): number { + return 0.2126 * linearize(rgb[0]) + 0.7152 * linearize(rgb[1]) + 0.0722 * linearize(rgb[2]); +} + +/** + * 若颜色相对亮度超过上限,就在**线性空间**里整体缩小(保持色相与色度不变), + * 让它与纯白的对比度达标。这保证任意种子/色相都不会产出太亮的底。 + */ +function clampLuminance(rgb: RGB, maxLum: number): RGB { + const l = luminance(rgb); + if (l <= maxLum) return rgb; + const k = maxLum / l; + return [ + toSrgb(linearize(rgb[0]) * k), + toSrgb(linearize(rgb[1]) * k), + toSrgb(linearize(rgb[2]) * k), + ]; +} + + /* ---------------------------------------------------------------- DOM 出口层 */ /** 像素缓冲 → dataURL。唯一用到 DOM 的层,浏览器里才调用。 */