用户 6 条原话(要点):
①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
任务显示标题+状态+**所在看板名**;最下面往年的今天。
④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
右上角给**登录**入口;展示页头像用**用户自己的头像**。
⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
⑥ 财务页第二张多线图不随类别标签筛选。
用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。
════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。
修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。
**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。
覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。
════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
(法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
→ 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
任务是标题 + **状态** + **看板名** + 截止
③ 往年的今天
新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。
════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。
搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。
免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。
路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。
════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。
`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。
════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。
════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。
════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
`time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
(实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
(重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。
════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
341 lines
14 KiB
PHP
341 lines
14 KiB
PHP
<?php
|
||
/**
|
||
* 模块注册中心 —— 「有哪些模块 / 谁在导航条上 / 各自什么主题色」。
|
||
*
|
||
* 为什么要有这么一张表(用户 2026-10-01 第 10、11 条):
|
||
* · 模块越来越多(观影 / 旅程 / 日程 / 资源 / 任务…),导航条不可能无限加;
|
||
* 每个人关心的不一样,得让他自己挑「哪几个出现在导航条上」并拖动排序。
|
||
* · 每个模块一个主题色,进到哪个模块一眼就认出来。
|
||
*
|
||
* 三条分工(别混):
|
||
* · **模块本身**(有哪些、叫什么、默认什么色)→ 这里的 LIST,写死在代码里;
|
||
* · **谁在导航条上 / 什么顺序** → user_settings('nav_modules'),**每人一份**;
|
||
* · **主题色** → settings('module_colors'),**家庭级**(一个家认同一套配色,
|
||
* 不然同一个"观影"在爸爸那屏是紫的、妈妈那屏是绿的,反而记不住)。
|
||
*
|
||
* ⚠️ 新增模块的唯一入口就是 LIST:加一行,导航条、主题色、个人设置里的开关
|
||
* 全部自动出现,不用再去改 layout.php 之类的地方(以前就是漏改一处 →
|
||
* 「有页面但导航条上找不到入口」)。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
final class Module
|
||
{
|
||
/**
|
||
* key => [导航条上的名字, 默认主题色, 路由]
|
||
*
|
||
* ⚠️ key 必须等于路由名(r=xxx):导航链接、pageKey 高亮、「本页属于哪个模块」
|
||
* 全都按 key 走,各写一份迟早对不上。
|
||
* ⚠️ 主题色一律挑**中低饱和、中低明度**的:按钮上是白字,太浅会看不清,
|
||
* 太艳会盖过内容(家书整体是宣纸米 + 砖红的暖色调,模块色只是分区,不是主色)。
|
||
*/
|
||
public const LIST = [
|
||
'home' => ['首页', '#A84A28', 'home'],
|
||
'kin' => ['家谱', '#8A6B4B', 'kin'],
|
||
'people' => ['名册', '#4A7C82', 'people'],
|
||
'movies' => ['观影', '#7A4E8C', 'movies'],
|
||
'trips' => ['旅程', '#3F7D5A', 'trips'],
|
||
'calendar' => ['日程', '#B0763A', 'calendar'],
|
||
'resources' => ['资源', '#5566A8', 'resources'],
|
||
'tasks' => ['任务', '#A8574F', 'tasks'],
|
||
// v24 第 13 条:纪念日(含名册里每个人的生日)
|
||
'days' => ['纪念日', '#B4633C', 'days'],
|
||
// v24 第 15 条:财务(账本 + 账目 + 共享)
|
||
'ledger' => ['财务', '#5E7A3C', 'ledger'],
|
||
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
|
||
'books' => ['读书', '#6B5B95', 'books'],
|
||
// v24 第 24 批 用户第 4 条:个人导航(从 SimpleNav 搬进来,放在**读书后面**)
|
||
// 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以
|
||
// 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。
|
||
'nav' => ['导航', '#3E7A9E', 'nav'],
|
||
];
|
||
|
||
/**
|
||
* 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。
|
||
*
|
||
* ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** ——
|
||
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
|
||
* 只是不再占顶栏的位置。
|
||
*/
|
||
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
|
||
'tasks', 'days', 'ledger', 'books', 'nav'];
|
||
|
||
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
|
||
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
|
||
|
||
/**
|
||
* 主题色的预设色板(个人设置里点一下就换)。
|
||
*
|
||
* ⚠️ 一律**中低饱和、中低明度**:cssVars() 会把主色压到 L≈0.42 当按钮底色、
|
||
* L≈0.28 当字色 —— 太浅则白字在按钮上看不清,太艳则盖过纸墨底的本色。
|
||
*/
|
||
public const PRESET_COLORS = [
|
||
'#A84A28', '#8A6B4B', '#4A7C82', '#7A4E8C', '#3F7D5A',
|
||
'#B0763A', '#5566A8', '#A8574F', '#5C8A48', '#6B5B95',
|
||
'#9A5C7A', '#3D6B8E',
|
||
];
|
||
|
||
/** 模块名(认不出的 key 返回空串,调用方自己兜底) */
|
||
public static function name(string $key): string
|
||
{
|
||
return self::LIST[$key][0] ?? '';
|
||
}
|
||
|
||
public static function route(string $key): string
|
||
{
|
||
return self::LIST[$key][2] ?? $key;
|
||
}
|
||
|
||
/** 模块是否存在(个人设置里的开关、路由白名单都靠它,别再用 in_array 手写列表) */
|
||
public static function exists(string $key): bool
|
||
{
|
||
return isset(self::LIST[$key]);
|
||
}
|
||
|
||
/** 全部模块 key(按 LIST 的顺序) */
|
||
public static function keys(): array
|
||
{
|
||
return array_keys(self::LIST);
|
||
}
|
||
|
||
// ---------------- 导航条:每人一份 ----------------
|
||
|
||
/**
|
||
* 这个人导航条上要显示哪些模块、什么顺序。
|
||
*
|
||
* ⚠️ 两道兜底,缺一不可:
|
||
* ① 存过的值里**只保留还存在的模块** —— 删模块/改 key 之后,
|
||
* 老配置里那条会变成一个认不出的 key,直接渲染就是一条空链接;
|
||
* ② LIST 里**新增的模块补到末尾** —— 否则"新装的模块在导航条上永远不出现",
|
||
* 用户会以为功能没做(这正是加这张表的动机)。
|
||
*/
|
||
public static function navFor(int $personId): array
|
||
{
|
||
$raw = (string)UserSetting::get($personId, 'nav_modules', '');
|
||
$dec = $raw !== '' ? json_decode($raw, true) : null;
|
||
if (!is_array($dec)) {
|
||
return self::DEFAULT_NAV; // 没配过 → 默认那一套(新装的人、重置过的)
|
||
}
|
||
$saved = [];
|
||
foreach ($dec as $k) {
|
||
$k = (string)$k;
|
||
if (self::exists($k) && !in_array($k, $saved, true)) {
|
||
$saved[] = $k;
|
||
}
|
||
}
|
||
|
||
// v24 一次性迁移:老用户存过配置、里面还留着家谱的,帮他撤下来。
|
||
// ⚠️ 只做一次(记 NAV_KIN_OFF_FLAG)—— 之后他自己在设置里勾回来就该尊重他,
|
||
// 否则每次请求都删一遍 = "家谱开关"永远失效。
|
||
if ($personId > 0
|
||
&& (string)UserSetting::get($personId, self::NAV_KIN_OFF_FLAG, '') === '') {
|
||
UserSetting::set($personId, self::NAV_KIN_OFF_FLAG, '1');
|
||
if (in_array('kin', $saved, true)) {
|
||
$saved = array_values(array_filter($saved, static fn($k) => $k !== 'kin'));
|
||
UserSetting::set($personId, 'nav_modules',
|
||
json_encode($saved, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
}
|
||
// ⚠️ 「新装的模块要自动出现」**不能**用"不在 saved 里就补"来实现 ——
|
||
// 那样用户**主动关掉**的模块下一次请求就会被补回来,
|
||
// 「可设置哪些模块出现在导航条」直接失效(实测:关掉观影,刷新它又回来了)。
|
||
// 所以另存一份 nav_known =「用户做这次决定时,世上已知的全部模块」:
|
||
// 只有 `现在的模块 - known` 才是**真的新装**、才该补进去。
|
||
$known = [];
|
||
$kraw = (string)UserSetting::get($personId, 'nav_known', '');
|
||
$kdec = $kraw !== '' ? json_decode($kraw, true) : null;
|
||
if (is_array($kdec)) {
|
||
foreach ($kdec as $k) {
|
||
$known[] = (string)$k;
|
||
}
|
||
}
|
||
foreach (self::keys() as $k) {
|
||
if (!in_array($k, $known, true)) {
|
||
$saved[] = $k; // 真·新装的模块:补到末尾,别让用户以为功能没做
|
||
}
|
||
}
|
||
return $saved ?: self::DEFAULT_NAV;
|
||
}
|
||
|
||
/**
|
||
* 保存导航配置。
|
||
*
|
||
* ⚠️ 传进来的顺序**原样保存**(拖动排序要的就是顺序),但只收认识的 key:
|
||
* 前端可能把「已关闭的模块」也一起 POST 上来,混进去就是"关了还在"。
|
||
*/
|
||
public static function saveNav(int $personId, array $keys): void
|
||
{
|
||
$out = [];
|
||
foreach ($keys as $k) {
|
||
$k = (string)$k;
|
||
if (self::exists($k) && !in_array($k, $out, true)) {
|
||
$out[] = $k;
|
||
}
|
||
}
|
||
if (!$out) {
|
||
$out = self::DEFAULT_NAV;
|
||
}
|
||
UserSetting::set($personId, 'nav_modules', json_encode($out, JSON_UNESCAPED_UNICODE));
|
||
// 顺手记下「做这次决定时已知的全部模块」——navFor() 靠它区分
|
||
// 「用户关掉的」与「后来新装的」(见那边的注释)。
|
||
UserSetting::set($personId, 'nav_known',
|
||
json_encode(self::keys(), JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
// ---------------- 主题色:家庭一份 ----------------
|
||
|
||
/** key => '#rrggbb'(没配过就用 LIST 里的默认色) */
|
||
public static function colors(int $familyId = 0): array
|
||
{
|
||
$out = [];
|
||
foreach (self::LIST as $k => $def) {
|
||
$out[$k] = $def[1];
|
||
}
|
||
// ⚠️ $familyId=0(安装期 / 还没登录)时就用默认色,不要为此炸掉整页
|
||
if ($familyId <= 0) {
|
||
return $out;
|
||
}
|
||
$raw = (string)Setting::get($familyId, 'module_colors', '');
|
||
if ($raw === '') {
|
||
return $out;
|
||
}
|
||
$dec = json_decode($raw, true);
|
||
if (is_array($dec)) {
|
||
foreach ($dec as $k => $v) {
|
||
$v = AvatarColor::normalize($v);
|
||
if (isset($out[$k]) && $v !== '') {
|
||
$out[$k] = $v;
|
||
}
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
public static function color(string $key, int $familyId = 0): string
|
||
{
|
||
$c = self::colors($familyId);
|
||
return $c[$key] ?? ($c['home'] ?? '#A84A28');
|
||
}
|
||
|
||
/**
|
||
* 把主题色铺成三个 CSS 变量。
|
||
*
|
||
* 为什么不只给一个 --brand:按钮的 hover(--brand-2)与文字(--brand-ink)
|
||
* 都是从主色推出来的深浅两档,只换主色会让 hover 变成别的颜色、或者
|
||
* 白字压在浅底上看不清。这里统一按 HSL 压明度,保证三档永远同色系。
|
||
*
|
||
* @return array{brand:string,brand2:string,ink:string}
|
||
*/
|
||
public static function cssVars(string $key, int $familyId = 0): array
|
||
{
|
||
$hex = self::color($key, $familyId);
|
||
[$h, $s] = self::hsl($hex);
|
||
$s = max(0.28, min(0.72, $s));
|
||
return [
|
||
'brand' => self::hslToHex($h, $s, 0.42),
|
||
'brand2' => self::hslToHex($h, $s, 0.58),
|
||
'ink' => self::hslToHex($h, $s, 0.28),
|
||
];
|
||
}
|
||
|
||
public static function saveColors(int $familyId, array $colors): void
|
||
{
|
||
$out = [];
|
||
foreach (self::keys() as $k) {
|
||
$v = AvatarColor::normalize($colors[$k] ?? null);
|
||
if ($v !== '') {
|
||
$out[$k] = $v;
|
||
}
|
||
}
|
||
Setting::set($familyId, 'module_colors', json_encode($out, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
/**
|
||
* 「本页属于哪个模块」—— 主题色与导航高亮都靠它。
|
||
*
|
||
* pageKey 可能是子页(movies/search、trips/show…),所以按**最长前缀**匹配到模块。
|
||
*/
|
||
public static function ofPage(string $pageKey): string
|
||
{
|
||
if (self::exists($pageKey)) {
|
||
return $pageKey;
|
||
}
|
||
$best = '';
|
||
foreach (self::keys() as $k) {
|
||
if (strpos($pageKey, $k . '/') === 0 || strpos($pageKey, $k . '_') === 0) {
|
||
if (strlen($k) > strlen($best)) {
|
||
$best = $k;
|
||
}
|
||
}
|
||
}
|
||
return $best;
|
||
}
|
||
|
||
// ---------------- HSL 小工具(与 AvatarColor 同一套算法,只是参数不同) ----------------
|
||
|
||
/** @return array{0:float,1:float,2:float} */
|
||
private static function hsl(string $hex): array
|
||
{
|
||
$hex = ltrim(AvatarColor::normalize($hex) ?: '#A84A28', '#');
|
||
$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];
|
||
}
|
||
$den = 1 - abs(2 * $l - 1);
|
||
$s = $den > 1e-9 ? $d / $den : 0.0;
|
||
$h = self::hue($r, $g, $b, $mx, $mn, $d);
|
||
return [$h, $s, $l];
|
||
}
|
||
|
||
private static function hue(float $r, float $g, float $b, float $mx, float $mn, float $d): float
|
||
{
|
||
if ($mx === $r) {
|
||
$h = fmod((($g - $b) / $d), 6.0);
|
||
} elseif ($mx === $g) {
|
||
$h = (($b - $r) / $d) + 2.0;
|
||
} else {
|
||
$h = (($r - $g) / $d) + 4.0;
|
||
}
|
||
$h *= 60.0;
|
||
return $h < 0 ? ($h + 360.0) / 360.0 : $h / 360.0;
|
||
}
|
||
|
||
/** HSL → '#rrggbb'(与 AvatarColor::hslToHex 同算法,这里独立一份只为不暴露私有方法) */
|
||
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)));
|
||
}
|
||
}
|