fix: 预设几何配色压暗降饱和,保证白字可读
- 线性空间限亮度:底色对纯白 WCAG ≥ ~7:1,最亮的强调色也不低于 ~4.9:1, 任意种子/色相都不会再产出浅亮底(之前空种子的草绿只有 2-3:1) - 饱和度整体下压、偏中性,不再和白字抢注意力 - 空种子不再共用同一个色相:每种底纹有专属默认色相 (条纹 215° 蓝 / 噪点 25° 琥珀 / 网格 265° 紫 / 同心弧 350° 玫红) - 缩略图标签加半透明深色底衬,白字不再直接压在图案上 - 确定性机制、纯函数拆分、三个来源结构均未动
This commit is contained in:
@@ -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 列 */
|
||||
|
||||
+70
-11
@@ -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<GeometryKind, number> = {
|
||||
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 的层,浏览器里才调用。 */
|
||||
|
||||
Reference in New Issue
Block a user