Files
FamilyBook/app/AvatarColor.php
T
laoyang 71a7ec35a0 v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00

293 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 头像主色 —— 给「人」一个稳定的识别色。
*
* 由来(用户 2026-10-01 的第 2 条反馈):观影人标签一律是灰底,十几个人排在一起
* 分不清谁是谁。既然每个人都有头像,就从头像里算出一种主色当背景色;
* 没有头像(或头像是纯灰)的人,按姓名算一个**稳定的**颜色兜底 ——
* 同一个人在任何页面、任何时候看到的颜色都一样。
*
* 三条刻意的设计:
* · **算一次、存库(persons.avatar_color),不要每次渲染都算**。
* 头像在 OSS 上,每渲染一个标签就下载一次原图 = 页面上 20 个人就是 20 次外网请求。
* 只在「上传/换头像」的那一刻算,之后就是读一列 CHAR(7) —— 成本趋零。
* · **主色不等于背景色**。头像是深色时主色也深,直接当背景会变成黑底黑字。
* 这里一律输出两个派生色:`bg()` 是"往白里兑"的浅色底(保证深色字读得清),
* `fg()` 是"往黑里压"的深色字(保证在浅底上够对比)。主色本身只作为**身份色**存库。
* · **灰片回退到姓名色**。证件照、黑白老照片算不出色相(饱和度极低),
* 硬取会得到一个飘忽不定的"最深的那个灰",每次换图颜色就跳一次 ——
* 不如干脆交给姓名哈希,稳定且可预期。
*/
declare(strict_types=1);
namespace App;
final class AvatarColor
{
/** 判定「这像素算不算有颜色」的色度门槛(max-min)。低于它当成灰。 */
private const CHROMA_MIN = 18;
/**
* 从一张 GD 图像里取主色,返回 '#rrggbb'。
* 取不到(纯灰/纯白/图坏)返回 '' —— 调用方再决定回退。
*
* 做法:缩到 64×64 采样 → 按色相分 36 个桶(每桶 10°)→ 以「色度 × 像素数」加权
* 选最重的桶 → 桶内按色度加权平均 RGB。比"整图取平均"稳得多:
* 整图平均会被大面积的背景(白墙)拖成一片灰,而这里白墙根本进不了桶。
*/
public static function fromGd($im): string
{
if (!$im) {
return '';
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 1 || $h < 1) {
return '';
}
// 先缩到 64×64:采样点数固定,一张 4000×3000 的原图也不会算很久
$small = $im;
if ($w > 64 || $h > 64) {
$scale = min(64 / $w, 64 / $h);
$sw = max(1, (int)round($w * $scale));
$sh = max(1, (int)round($h * $scale));
$tmp = imagecreatetruecolor($sw, $sh);
if (!$tmp) {
return '';
}
imagecopyresampled($tmp, $im, 0, 0, 0, 0, $sw, $sh, $w, $h);
$small = $tmp;
$w = $sw;
$h = $sh;
}
$buckets = []; // hue桶 => [sumR, sumG, sumB, weight]
for ($y = 0; $y < $h; $y++) {
for ($x = 0; $x < $w; $x++) {
$rgb = imagecolorat($small, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$mx = max($r, $g, $b);
$mn = min($r, $g, $b);
$chroma = $mx - $mn;
if ($chroma < self::CHROMA_MIN) {
continue; // 灰/白/黑:不参与
}
if ($mx > 246 || $mn < 12) {
continue; // 死白 / 死黑:多半是背景或阴影
}
$hue = self::hueOf($r, $g, $b, $mx, $mn, $chroma);
$bucket = (int)floor($hue / 10);
if ($bucket >= 36) {
$bucket = 35;
}
// 权重 = 色度(越鲜艳越能代表这张图)× 一个"别太暗也别太亮"的系数
$weight = $chroma * (1 - abs(($mx + $mn) / 510 - 0.5));
if (!isset($buckets[$bucket])) {
$buckets[$bucket] = [0, 0, 0, 0.0];
}
$buckets[$bucket][0] += $r * $weight;
$buckets[$bucket][1] += $g * $weight;
$buckets[$bucket][2] += $b * $weight;
$buckets[$bucket][3] += $weight;
}
}
if ($small !== $im) {
imagedestroy($small);
}
if (!$buckets) {
return '';
}
// 取权重最大的桶
$best = null;
$bestW = -1.0;
foreach ($buckets as $b) {
if ($b[3] > $bestW) {
$bestW = $b[3];
$best = $b;
}
}
if ($best === null || $best[3] <= 0) {
return '';
}
return sprintf('#%02X%02X%02X',
(int)round($best[0] / $best[3]),
(int)round($best[1] / $best[3]),
(int)round($best[2] / $best[3]));
}
/** 从图片二进制里取主色(OSS/本地都行);失败返回 '' */
public static function fromBinary(string $bin): string
{
if ($bin === '') {
return '';
}
$im = @imagecreatefromstring($bin);
if ($im === false) {
return '';
}
$hex = self::fromGd($im);
imagedestroy($im);
return $hex;
}
/**
* 姓名兜底色:同样的人永远同一个颜色。
*
* 色相由姓名的 md5 决定(36 个色相里挑一个),饱和度与亮度**写死**在好看的区间内
* —— 不用哈希去碰 S/L,那样会随机出刺眼的荧光色或看不清的深色。
*/
public static function fromName(string $name): string
{
$name = trim($name);
$seed = $name === '' ? '?' : $name;
// ⚠️ 色相取 **0.1° 精度**(3600 档)而不是 360 档:派生出来的底色只保留色相
// (S/L 都被 bg()/fg() 拉到固定区间),所以两个人要能区分,只能靠色相。
// 360 档时十几个人里就撞色(实测:7 个名字里有两组落到同一个色)。
// 干脆用 md5 的整个 32 位当连续色相,不再分档。
$hue = hexdec(substr(md5($seed), 0, 8)) / 4294967296.0;
return self::hslToHex($hue, 0.55, 0.45);
}
/**
* 一个人的展示色:库里算过就用库里的,没算过按姓名兜底。
* 页面上一律走这里 —— 不许各自 `$p['avatar_color'] ?: '#888'`。
*/
public static function for(?array $p): string
{
if (!$p) {
return self::fromName('');
}
$c = self::normalize($p['avatar_color'] ?? null);
if ($c !== '') {
return $c;
}
return self::fromName((string)($p['name'] ?? ''));
}
/** 规整成 '#rrggbb';认不出来返回 '' */
public static function normalize($hex): string
{
$hex = strtolower(trim((string)$hex));
if ($hex === '') {
return '';
}
if ($hex[0] === '#') {
$hex = substr($hex, 1);
}
if (preg_match('/^[0-9a-f]{3}$/', $hex)) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (!preg_match('/^[0-9a-f]{6}$/', $hex)) {
return '';
}
return '#' . $hex;
}
/**
* 背景色:把主色往白里兑。
*
* 兑完统一到 L≈0.90、S≈0.55 —— 不管头像是深蓝还是亮黄,
* 出来的都是"淡淡的那种蓝/黄",深色字压在上面都读得清,
* 同时彼此之间又能一眼区分(这正是用户要的效果)。
*/
public static function bg(?string $hex): string
{
[$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName(''));
$s = $s < 0.18 ? 0.30 : min(0.62, max(0.34, $s));
return self::hslToHex($h, $s, 0.90);
}
/** 文字色:把主色往黑里压,保证在 bg() 上有足够对比 */
public static function fg(?string $hex): string
{
[$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName(''));
$s = $s < 0.18 ? 0.35 : min(0.75, max(0.40, $s));
return self::hslToHex($h, $s, 0.30);
}
/** 边线色(比 bg 深一档,让浅色标签在白底上仍有轮廓) */
public static function bd(?string $hex): string
{
[$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName(''));
$s = $s < 0.18 ? 0.35 : min(0.70, max(0.38, $s));
return self::hslToHex($h, $s, 0.72);
}
// ---------------- 颜色空间小工具 ----------------
/** 0..360 的色相 */
private static function hueOf(int $r, int $g, int $b, int $mx, int $mn, int $chroma): float
{
if ($chroma === 0) {
return 0.0;
}
if ($mx === $r) {
$h = fmod((($g - $b) / $chroma), 6.0);
} elseif ($mx === $g) {
$h = (($b - $r) / $chroma) + 2.0;
} else {
$h = (($r - $g) / $chroma) + 4.0;
}
$h *= 60.0;
if ($h < 0) {
$h += 360.0;
}
return $h;
}
/** @return array{0:float,1:float,2:float} h(0..1) s(0..1) l(0..1) */
private static function hsl(string $hex): array
{
$hex = ltrim($hex, '#');
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$mx = max($r, $g, $b);
$mn = min($r, $g, $b);
$l = ($mx + $mn) / 2;
$d = $mx - $mn;
if ($d == 0) {
return [0.0, 0.0, $l];
}
// 分母在 l=0(死黑)/ l=1(死白)时为 0 —— 那时饱和度本来就没有意义,记 0。
$den = 1 - abs(2 * $l - 1);
$s = $den > 1e-9 ? $d / $den : 0.0;
$h = self::hueOf(
(int)round($r * 255), (int)round($g * 255), (int)round($b * 255),
(int)round($mx * 255), (int)round($mn * 255), max(1, (int)round($d * 255))
);
return [$h / 360.0, $s, $l];
}
private static function hslToHex(float $h, float $s, float $l): string
{
$h = $h - floor($h);
if ($s <= 0) {
$v = (int)round($l * 255);
return sprintf('#%02X%02X%02X', $v, $v, $v);
}
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
$p = 2 * $l - $q;
$conv = static function (float $t) use ($p, $q): float {
$t = $t - floor($t);
if ($t < 1 / 6) {
return $p + ($q - $p) * 6 * $t;
}
if ($t < 1 / 2) {
return $q;
}
if ($t < 2 / 3) {
return $p + ($q - $p) * (2 / 3 - $t) * 6;
}
return $p;
};
$r = (int)round($conv($h + 1 / 3) * 255);
$g = (int)round($conv($h) * 255);
$b = (int)round($conv($h - 1 / 3) * 255);
return sprintf('#%02X%02X%02X', max(0, min(255, $r)), max(0, min(255, $g)), max(0, min(255, $b)));
}
}