Commit b85ac222 by luoqi

feat(矩阵): 悬停 = 瞄准 —— 角括号准星 + 行列十字

产品:「鼠标可以标出忽大忽小的瞄靶,其他动效奇思妙想一下」。
点下去已经有一串粒子飞向助手(pacStreamFly)——悬停这一段就接在它前面:**瞄准,然后发射**。

── 一件事的两半 ────────────────────────────────────────────────
· **准星**(globals.css `.pac-reticle`)—— 你正指着**哪一格**
  四角角括号,零 DOM 零 SVG(8 条渐变画四个角,随格子尺寸自适应)。
  两段动画:先「咬合」(0.18s 从格子外一口收进来),再「呼吸」(2.4s,±1.5px)。
  ️ 呼吸幅度刻意只有 1.5px —— 再大就从"锁定中"变成"这里出错了"。
  按下时收紧 3.5px 并停住:扣扳机的那一下,紧接着交给粒子。
· **十字**(组件里的行/列高亮)—— 那一格**是谁 × 什么时候**
  8 行 × 6 列,格子离行头和列头都很远;光把格子框起来答不了后一半。
  命中行:行标签右移 2px + 上主色(位移比变色更早被眼睛捕捉到,而那列字只有两三个)。
  命中列:列头圆点长大一圈,当这条轴的刻度指针。
  其余整片退让(opacity 35~45%), 不是给命中的加亮 —— 加亮会动到色相,而色相本身是信息。

── ️ 几条老纪律都没破 ─────────────────────────────────────────
· 「hover  别换底色,会在渐变面上戳一个洞」管的是**一格**单独换底;
  这里是**一行 + 一列**,读出来是十字不是洞,且瞬时、 从不由数量驱动。白纱封顶 8%。
· 实线框留给「**已经选中**」,准星是「**正指着**」—— 两个状态长得一样就分不出,
  而这一点下去人就发出去了。
· focus-visible 仍走实线 outline:键盘可达性 不能只靠一段动画。
· `prefers-reduced-motion`:准星照旧画出来(它是**信息**),只是不动。

🔴 `pac-reticle` 由 **state** 挂, 不写成 CSS 的 `:hover::after`:
  0 人的格子是 disabled,而 disabled 的 hover 跨浏览器不一致 —— 走 state 才拦得住;
  且准星与十字必须同生同灭,同一个来源才不会一个亮一个不亮。
️ 整片渐变面上挂 `onMouseLeave` 兜底清空:只靠每格的 leave,鼠标直接飞出容器时
  有概率被下一个 enter 抢先,十字会留在原地不走。

验证:tsc 绿,next build 通过;产物 CSS 里 `.pac-reticle::after`、两个 keyframes、
`:active` 与 reduced-motion 分支都在,白纱编成 #ffffff14。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent e6558355
......@@ -297,6 +297,73 @@ body {
.pac-stream-dot { display: none; }
}
/* ── 初选矩阵:悬停 = **瞄准**(点下去才是发射,接上面那串粒子)────────────
⚠️ 这一整段只在 `pool-matrix` 用。它做的是**一件事的两半**:
· 准星(这里)—— 你正指着**哪一格**;
· 十字(组件里的行/列高亮)—— 那一格**是谁 × 什么时候**。
8 行 × 6 列的表,格子离行头和列头都很远,光把格子框起来答不了后一半。
🔴 用**角括号**而不是整圈实线框:
实线框留给「**已经选中**」(组件里 `selected` 那一支)。两个状态长得一样的话,
"我正指着" 和 "我已经选了" 就分不出来 —— 而这一点下去人就发出去了。
⚠️ 画在**伪元素**上,⛔ 不画在按钮自己的 background 上:
按钮的底色要留给十字那层薄纱,两者叠在一起时得各归各的图层。
⚠️ 这个类由组件**按 state 挂**(只挂在瞄中的那一格),所以下面 `::after` ⛔ 不加 `:hover` ——
0 人的格子是 disabled,而 disabled 的 hover 跨浏览器不一致,靠 CSS 拦不住。 */
.pac-reticle { position: relative; }
.pac-reticle::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
--pac-ret-c: var(--color-brand-600);
--pac-ret-b: 1.5px; /* 线宽 */
--pac-ret-l: 7px; /* 每个角的臂长 */
/* 四个角 = 8 条渐变(每角一横一竖)。⚠️ 这样零 DOM、零 SVG,
而且随格子尺寸自适应(角永远贴在四角,中间留空)。 */
background-image:
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c));
background-repeat: no-repeat;
background-size:
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l),
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l),
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l),
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l);
background-position:
0 0, 0 0,
100% 0, 100% 0,
0 100%, 0 100%,
100% 100%, 100% 100%;
/* ⭐ 两段:先**咬合**(从格子外一口收进来),再**呼吸**(产品要的"忽大忽小")。
⚠️ 呼吸幅度只有 1.5px、周期 2.4s —— 再大就从"锁定中"变成"这里出错了"。 */
animation:
pacReticleLock 0.18s cubic-bezier(0.2, 1.3, 0.4, 1),
pacReticleBreathe 2.4s ease-in-out 0.18s infinite;
}
/* 扣扳机的那一下:准星收紧,紧接着交给粒子(pacStreamFly) */
.pac-reticle:active::after { inset: 3.5px; animation: none; transition: inset 0.08s ease-out; }
@keyframes pacReticleLock {
from { inset: -2px; opacity: 0; }
to { inset: 2px; opacity: 1; }
}
@keyframes pacReticleBreathe {
0%, 100% { inset: 2px; opacity: 1; }
50% { inset: 3.5px; opacity: 0.7; }
}
@media (prefers-reduced-motion: reduce) {
/* 无障碍:准星照旧画出来(它是**信息**:你指着哪一格),只是不动 */
.pac-reticle::after { animation: none; }
}
/* ── 「分一批新的 · 里世界」传送门 —— **2026-08-11 删** ──────────────
这里原来是那版全屏「打地鼠」的动效:一个 200×200 的小圆从按钮位置
`transform: scale()` 放大盖满全屏(`.pac-portal-disc`),内容随后 `opacity` 淡入
......
'use client';
import { useState } from 'react';
import {
TEMPERATURE_META,
TEMPERATURE_ORDER,
......@@ -118,6 +119,18 @@ export function PoolMatrix({
}) => void;
}) {
const showUnknown = data.unknownTotal > 0;
/**
* ⭐ **正指着哪一格** —— 悬停的行 × 列坐标(2026-08-19 产品要「瞄靶」时一起加的)。
*
* 🔴 为什么要提到这一层、⛔ 不留在 CSS 的 `:hover` 里:
* `:hover` 只够点亮**格子自己**,而这张表 8 行 × 6 列、格子离行头和列头都很远 ——
* 人指着一格时真正要确认的是「这是**哪个治疗** × **哪个时间档**」,
* 那两个字在表的最左和最上。⇒ 坐标必须能传给行标签和列头,那就得是 state。
* ⚠️ 只在**有人可分**的格子上设(`n > 0`):0 格点不动,给它十字等于指了个空。
* ⚠️ 整片 `onMouseLeave` 兜底清空 —— ⛔ 别只靠每个格子的 leave:
* 鼠标从格子直接飞出容器时,leave 有概率被下一个 enter 抢在前面,十字就留在原地不走了。
*/
const [aim, setAim] = useState<{ treatment: string; temperature: TempKey } | null>(null);
return (
<Card className={cn('w-[560px] border-0 shadow-none', className)}>
{/*
......@@ -136,20 +149,35 @@ export function PoolMatrix({
<div className="flex items-center pb-1.5">
<div className={cn(LABEL_W, 'shrink-0')} />
<div className="flex flex-1">
{COLUMNS.map((c) => (
{COLUMNS.map((c) => {
const onAxis = aim?.temperature === c;
return (
<div
key={c}
title={TEMPERATURE_META[c].hint}
className={cn(
// ⚠️ whitespace-nowrap:「3 年以上」会折成两行,把整排列头撑高、基线也歪
'flex flex-1 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-semibold',
'transition-opacity duration-150',
HUE[c].text,
// ⭐ 命中那一列**保持原样**,其余**退让** —— ⛔ 不是给命中的那列加亮:
// 加亮会让六个列头在悬停时颜色各不相同(色相本身是信息,见 HUE 那段);
// 压暗其余的既不动色相,又让"我在这一列"一眼看出来。
aim && !onAxis && 'opacity-35',
)}
>
<span className={cn('h-1.5 w-1.5 rounded-sm', HUE[c].dot)} />
<span
className={cn(
'rounded-sm transition-all duration-150',
HUE[c].dot,
// 命中列的圆点长大一圈 —— 它是这条轴的"刻度指针"
onAxis ? 'h-2 w-2' : 'h-1.5 w-1.5',
)}
/>
{TEMPERATURE_META[c].zh}
</div>
))}
);
})}
</div>
{showUnknown && (
<div className="w-12 shrink-0 text-center text-[11px] whitespace-nowrap text-muted-foreground">算不出</div>
......@@ -159,17 +187,40 @@ export function PoolMatrix({
<div className="flex items-start">
{/* 行标签独立成列 —— 它不该压在渐变面上(压上去白底文字会跟着色相变脏) */}
<div className={cn(LABEL_W, 'shrink-0')}>
{data.rows.map((row) => (
<div key={row.key} className={cn(ROW_H, 'flex items-center text-xs font-medium')}>
{data.rows.map((row) => {
const onAxis = aim?.treatment === row.key;
return (
<div
key={row.key}
className={cn(
ROW_H,
'flex items-center text-xs font-medium transition-all duration-150',
// ⚠️ 命中那一行**往右挪 2px + 上主色**,其余压暗 —— 位移比变色更早被眼睛捕捉到,
// 而这一列字很短(种植/早矫/拔牙),只变色容易被数字那片抢掉。
onAxis && 'translate-x-0.5 text-brand-700',
aim && !onAxis && 'opacity-35',
)}
>
{potentialTreatmentItemName(row.key)}
</div>
))}
);
})}
</div>
{/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */}
<div className={cn('flex-1 overflow-hidden rounded', PLANE)}>
<div
className={cn('flex-1 overflow-hidden rounded', PLANE)}
onMouseLeave={() => setAim(null)}
>
{data.rows.map((row) => (
<MatrixRow key={row.key} row={row} selected={selected} onPick={onPick} />
<MatrixRow
key={row.key}
row={row}
selected={selected}
onPick={onPick}
aim={aim}
onAim={setAim}
/>
))}
</div>
......@@ -216,6 +267,8 @@ function MatrixRow({
row,
selected,
onPick,
aim,
onAim,
}: {
row: PoolMatrixRow;
selected?: { treatment: string; temperature: TempKey } | null;
......@@ -226,6 +279,9 @@ function MatrixRow({
count: number;
rect: { x: number; y: number; w: number; h: number };
}) => void;
/** 正指着哪一格(见 PoolMatrix 里那段) */
aim: { treatment: string; temperature: TempKey } | null;
onAim: (a: { treatment: string; temperature: TempKey } | null) => void;
}) {
// ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处。
// 用**项目名**(种植 / 早矫 / 拔牙)而不是卡片措辞(种植治疗):
......@@ -236,11 +292,16 @@ function MatrixRow({
{COLUMNS.map((c) => {
const n = row.counts[c] ?? 0;
const isSelected = selected?.treatment === row.key && selected.temperature === c;
// 十字:同一行 或 同一列(交点就是准星那一格)
const onCross = aim != null && (aim.treatment === row.key || aim.temperature === c);
const isAimed = aim?.treatment === row.key && aim.temperature === c;
return (
<button
key={c}
type="button"
disabled={n === 0}
// ⚠️ 只有**能点**的格子才设瞄点:0 格点不动,给它十字等于指了个空
onMouseEnter={() => n > 0 && onAim({ treatment: row.key, temperature: c })}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
onPick({
......@@ -266,11 +327,35 @@ function MatrixRow({
// 会把带 var() 的任意值解析成"只有颜色的阴影",偏移和扩散全丢,
// 实测算出来是 `oklab(0 0 0 / 0) 0 0 0 0 inset` —— 完全透明,**看不出坏了**。
// 负 offset 让描边画在格子内侧,与相邻格子不互相挤压(设计稿也是这么写的)。
'transition-[background-color,opacity,transform] duration-150',
n === 0
? 'cursor-default text-slate-900/35'
// ⚠️ 必须带 `outline-solid`:基态的 `outline-none` 把 outline-style 钉成 none,
// 只加 `outline-2` 只改宽度 —— 实测算出来是 width:2px 但 style:none,**一条线都不画**。
: 'cursor-pointer font-medium outline-none hover:outline-solid hover:outline-2 hover:-outline-offset-2 hover:outline-brand-600 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-600',
// 🔴 hover 那一圈**换成角括号准星**(`.pac-reticle`,globals.css)——
// 实线框留给「已经选中」,两个状态长得一样就分不出"正指着"和"已选了"。
// focus-visible **仍走实线 outline**:键盘可达性⛔ 不能只靠一段动画。
: 'cursor-pointer font-medium outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-600',
/**
* ⭐ 十字那层**薄纱**。
* ⚠️ 与文件头「hover ⛔ 别换底色,会在渐变面上戳一个洞」⛔ 不冲突:
* 那条管的是**一个格子**单独换底 —— 一个亮块浮在连续面上确实是个洞。
* 而这里是**一整行 + 一整列**,读出来是一个十字,不是一个洞;
* 且它是**瞬时交互态**,鼠标一走就没,⛔ 从不由数量驱动(那条纪律没动)。
* ⚠️ 白纱 8% 是上限:再浓就把底下"由烫到冷"那条轴洗白了。
*/
onCross && !isAimed && 'bg-white/8',
/**
* 准星那一格。
* ⚠️ `pac-reticle` **由 state 挂,⛔ 不写成 CSS 的 `:hover::after`**:
* ① 0 格是 `disabled`,而 disabled 元素的 hover 行为跨浏览器不一致 ——
* 走 state 才能保证"点不动的格子不给准星";
* ② 准星和十字必须**同生同灭**,同一个来源才不会一个亮一个不亮。
* ⚠️ 数字**微微抬起** —— `nums` 是等宽数字,放大不会把邻居推歪。
*/
isAimed && 'pac-reticle scale-[1.06] bg-white/12',
// 其余整片退让 —— ⚠️ 只压**不在十字上**的,压全部等于什么都没压
aim && !onCross && 'opacity-45',
isSelected && 'outline-solid outline-2 -outline-offset-2 outline-brand-600',
)}
>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment