优化工作区新增分类颜色选择

- 将工作区右侧面板“自定义分类”文案改为“新增分类”,并补充展开按钮的可访问标签。

- 新增可见颜色色块,保留原生颜色选择能力,解决深色面板中颜色框不明显的问题。

- 新增分类默认颜色改为随机从候选色中选择,并避开当前模板已有类别颜色。

- 增加颜色生成工具及单元测试,补充 OntologyInspector 组件测试覆盖新增分类色块和默认颜色避重。

- 更新前端审计、交互状态机和测试计划文档,记录新增分类颜色交互规则。
This commit is contained in:
2026-05-09 16:53:57 +08:00
parent 0ca1fed9d4
commit 384822d3ea
7 changed files with 211 additions and 15 deletions

View File

@@ -143,7 +143,7 @@
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`默认低对比融入深色侧栏hover/focus 时才增强显示 |
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes点击分类会设为后续新 mask 的 activeClass如果 Canvas 无选中 mask则不会改变已有 mask如果 Canvas 已选 mask则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class并滚动/聚焦到对应分类按钮 |
| 添加自定义分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store |
| 新增分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store;面板显示可见颜色色块,打开新增表单时会随机选择一个不与当前模板已有类别重复的默认颜色 |
| 目标实例属性标题 | 真实可用 | “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不再跟随全局 active class避免点过其他分类后标题固定成旧分类 |
| 当前选中区域计数 | 已移除 | 当前交互以单选 mask 为主,计数长期为 1属于低价值信息已从实例属性面板删除 |
| 后端拓扑锚点数量 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,后端按 polygon 的真实顶点数量返回 `topology_anchor_count``topology_anchors` 列表只保留最多 64 个抽样点用于调试展示,避免把真实数量误压成十几个;前端会忽略被浏览器中止或已过期的分析请求,避免切换 mask、拖动平滑预览或卸载组件时出现误报 |

View File

@@ -22,7 +22,7 @@
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、dirty 本地旧 annotationId 预检缺失时直接重新 POST 创建、预检后 PATCH 404 时重新 POST 创建并回显替换、中空 mask 保存为 `polygons` + `holes` 并可回显为 ring 分组、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 |
| R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、删除模板站内确认、鼠标复制模板为私有副本、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页“语义分类树(拖拽调层级)”标题、详情页“编辑模板”按钮和编辑图标、详情页垃圾桶删除 label 且不显示来源标签、编辑弹窗分类编辑不显示旧 category 来源元信息、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、`[[colors],[names]]` 数组格式、`{colors,names}` 对象格式、带前缀/宽松 keys/中文标点粘贴格式、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD |
| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、已有 mask 时切换激活模板需确认并清空所有 mask/标注、无 mask 时直接切换、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、无选中 mask 时点击分类只设置后续新建类别且不改已有 mask、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、自定义分类 PATCH 后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 |
| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts`, `src/lib/classColors.test.ts`, `backend/tests/test_ai.py` | 模板选择、已有 mask 时切换激活模板需确认并清空所有 mask/标注、无 mask 时直接切换、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、无选中 mask 时点击分类只设置后续新建类别且不改已有 mask、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、新增分类 PATCH 后端模板、显示新增分类颜色色块、随机生成不与当前模板重复的新增分类默认颜色、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 |
| R10 Dashboard 与 WebSocket | `src/lib/api.test.ts`, `src/lib/websocket.test.ts`, `src/components/Dashboard.test.tsx`, `backend/tests/test_dashboard.py`, `backend/tests/test_main.py`, `backend/tests/test_progress_events.py`, `backend/tests/test_tasks.py` | 后端概览接口、任务表驱动进度区、最近完成任务保留显示、任务取消/重试/详情、cancelled 事件、Redis 进度事件 payload/发布、地址推导、消息订阅、连接状态回调、队列更新、heartbeat、主动断开不重连 |
| R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | 统一分割结果导出按钮使用导出图标和绿色强调背景、统一分割结果导出下拉、导出前自动保存、整体/范围/当前帧范围参数、特定范围帧可通过播放进度条/视频处理进度条拖拽选择、下载 ZIP 按项目名/`0h00m00s000ms` 起止时间戳/起止项目帧序号命名、导出内容 outputs 参数、Mix_label 透明度参数和预览、兼容 COCO/PNG 路径、JSON 结构、maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/按类别合并的分开 Mask 文件夹、GT_label 黑白图文件夹、Pro_label 彩色图文件夹、Mix_label 原图叠加图文件夹、GT/Pro/Mix 按内部优先级覆盖且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类在 GT_label/Pro_label 中与背景同为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出 GT_label 再导入保持类别一致 |
| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 |
@@ -41,7 +41,7 @@
| R6 | SAM 2.1 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空 seed holes 栅格化扣除和传播结果 holes 提取、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
| R7 | 保存状态按钮“保存 X 个改动/已全部保存”、保存、保存后替换已提交 draft、查询、更新、dirty 本地旧 annotationId 的预检缺失直接重新创建和 PATCH 404 重新创建、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 数据兼容、导入 mask 不显示黄色 seed point、高精度 GT contour、导入 mask 拓扑统计和边缘平滑、8-bit 低数值 GT_label 图导入、16-bit/uint16 GT_label 图拒绝、全背景 0 GT_label 图拒绝并保留“没有非背景 maskid 区域”提示、RGB 等通道 maskid 图导入、导入预览、未知 maskid 导入策略、非法彩色 GT mask 拒绝、尺寸不一致自动最近邻拉伸 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
| R8 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 maskid/颜色/层级/规则、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页标题/编辑模板按钮/垃圾桶删 label、编辑弹窗分类编辑不显示旧 category 来源元信息、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、头颈部 CT 默认分类名纯中文且不带括号英文翻译、恢复出厂设置保留并权威恢复系统模板、默认模板缺失后重建、默认语义分类树被修改/删减后覆盖恢复、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、数组/对象/常见粘贴格式导入、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py`, `test_admin.py` | 已覆盖 |
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、自定义分类写入后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 |
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、新增分类写入后端模板、新增分类颜色色块和默认颜色避重、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `classColors.test.ts`, `test_ai.py` | 已覆盖 |
| R10 | Dashboard 概览、任务进度区、最近完成任务保留显示、活动日志、WebSocket progress/complete/error/status/cancelled、取消/重试/详情、连接状态回调、heartbeat | `Dashboard.test.tsx`, `websocket.test.ts`, `test_dashboard.py`, `test_main.py`, `test_progress_events.py`, `test_tasks.py` | 已覆盖 |
| R11 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、ZIP 文件名 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`、时间戳 `0h00m00s000ms` 格式、项目帧序号使用抽帧后 1-based 顺序、分开 Mask/GT_label/Pro_label/Mix_label outputs、Mix_label 透明度、导出前保存、兼容 COCO/PNG ZIP 路径、JSON/ZIP 结构、maskid/GT 像素值映射、原始图片导出、分开 Mask 按帧子目录与同类合并命名、GT_label/Pro_label/Mix_label 命名、GT/Pro/Mix 内部优先级融合且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类导出为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出的 GT_label 可按同一模板导回 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 |
| R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 |

View File

@@ -41,6 +41,7 @@
| 无选中 mask | 仅更新 `activeClass` | 后续新建 mask 使用该类别;已有 mask 不变 | `OntologyInspector.test.tsx` |
| 有选中 mask | 更新已选 mask 的 class/label/color同传播链对应 mask 同步更新 | 已保存 mask 标记为 dirty已选 mask 移到前端渲染数组末尾 | `OntologyInspector.test.tsx` |
| 当前 mask 的类别被删除 | 工作区回显时降级为 `maskid:0` “待分类” | 保留几何并等待用户重新分类保存 | `VideoWorkspace.test.tsx` |
| 新增分类 | 打开新增表单时生成一个与当前模板已有颜色不同的默认色,并显示可见色块;保存后写入当前模板并设为当前 active class | 新建/后续 mask 使用新增类别 | `OntologyInspector.test.tsx``classColors.test.ts` |
## 键盘交互

View File

@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
import { OntologyInspector } from './OntologyInspector';
const apiMock = vi.hoisted(() => ({
analyzeMask: vi.fn(),
deleteAnnotation: vi.fn(),
smoothMaskGeometry: vi.fn(),
updateTemplate: vi.fn(),
}));
vi.mock('../lib/api', () => ({
analyzeMask: apiMock.analyzeMask,
deleteAnnotation: apiMock.deleteAnnotation,
smoothMaskGeometry: apiMock.smoothMaskGeometry,
updateTemplate: apiMock.updateTemplate,
}));
describe('OntologyInspector', () => {
beforeEach(() => {
resetStore();
vi.clearAllMocks();
useStore.setState({
templates: [
{
id: 'template-1',
name: '测试模板',
classes: [
{ id: 'class-1', name: '胆囊', color: '#ef4444', zIndex: 20, maskId: 1 },
{ id: 'class-2', name: '肝脏', color: '#f97316', zIndex: 10, maskId: 2 },
{ id: 'reserved-unclassified', name: '待分类', color: '#000000', zIndex: 0, maskId: 0 },
],
},
],
activeTemplateId: 'template-1',
});
});
it('shows the add-class form with a visible color swatch and updated label', () => {
render(<OntologyInspector />);
expect(screen.getByText('新增分类')).toBeInTheDocument();
expect(screen.queryByText('自定义分类')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '展开新增分类' }));
const colorInput = screen.getByLabelText('新增分类颜色');
const colorSwatch = screen.getByTestId('new-class-color-swatch');
const selectedColor = (colorInput as HTMLInputElement).value;
expect(colorInput).toHaveAttribute('type', 'color');
expect(colorInput).not.toHaveValue('#ef4444');
expect(colorInput).not.toHaveValue('#f97316');
expect(colorSwatch).toHaveStyle(`background-color: ${selectedColor}`);
});
});

View File

@@ -6,6 +6,7 @@ import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
import { pickDistinctClassColor } from '../lib/classColors';
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
@@ -91,7 +92,7 @@ export function OntologyInspector() {
const [showAddForm, setShowAddForm] = useState(false);
const [newClassName, setNewClassName] = useState('');
const [newClassColor, setNewClassColor] = useState('#06b6d4');
const [newClassColor, setNewClassColor] = useState(() => pickDistinctClassColor([]));
const [isSavingClass, setIsSavingClass] = useState(false);
const [classSaveMessage, setClassSaveMessage] = useState('');
const [dragClassId, setDragClassId] = useState<string | null>(null);
@@ -129,6 +130,20 @@ export function OntologyInspector() {
smoothingPreviewTimerRef.current = null;
}, []);
const pickNextClassColor = React.useCallback(() => (
pickDistinctClassColor(templateClasses.map((templateClass) => templateClass.color))
), [templateClasses]);
const toggleAddForm = React.useCallback(() => {
setShowAddForm((current) => {
const next = !current;
if (next) {
setNewClassColor(pickNextClassColor());
}
return next;
});
}, [pickNextClassColor]);
const selectedMaskClass = useMemo(() => {
if (!selectedMask) return null;
const allTemplateClasses = templates.flatMap((template) => (
@@ -542,11 +557,12 @@ export function OntologyInspector() {
setActiveTemplateId(updated.id);
handleSelectClass(newClass);
setNewClassName('');
setNewClassColor(pickDistinctClassColor([...templateClasses.map((templateClass) => templateClass.color), newClass.color]));
setShowAddForm(false);
setClassSaveMessage('自定义分类已保存到后端模板');
setClassSaveMessage('新增分类已保存到后端模板');
} catch (err) {
console.error('Save custom class failed:', err);
setClassSaveMessage('自定义分类保存失败');
setClassSaveMessage('新增分类保存失败');
} finally {
setIsSavingClass(false);
}
@@ -729,9 +745,11 @@ export function OntologyInspector() {
{/* Add Custom Class */}
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest"></h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest"></h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
type="button"
aria-label={showAddForm ? '收起新增分类' : '展开新增分类'}
onClick={toggleAddForm}
className="text-cyan-400 hover:text-cyan-300 transition-colors"
>
<Plus size={12} />
@@ -740,12 +758,20 @@ export function OntologyInspector() {
{showAddForm && (
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<label className="relative flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-white/20 bg-white/5 shadow-inner shadow-black/40" title={`新增分类颜色 ${newClassColor}`}>
<span
data-testid="new-class-color-swatch"
className="h-5 w-5 rounded-sm border border-white/50 shadow-sm shadow-black"
style={{ backgroundColor: newClassColor }}
/>
<input
aria-label="新增分类颜色"
type="color"
value={newClassColor}
onChange={(e) => setNewClassColor(e.target.value)}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer"
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<input
type="text"
value={newClassName}
@@ -757,7 +783,7 @@ export function OntologyInspector() {
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
{isSavingClass ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
</button>
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
<button type="button" onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
<X size={14} />
</button>
</div>

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { pickDistinctClassColor } from './classColors';
describe('classColors', () => {
it('picks a palette color that is not already used', () => {
const color = pickDistinctClassColor(['#ef4444', '#f97316'], () => 0);
expect(color).toBe('#f59e0b');
});
it('falls back to generated colors when the palette is exhausted', () => {
const usedColors = [
'#ef4444',
'#f97316',
'#f59e0b',
'#eab308',
'#84cc16',
'#22c55e',
'#10b981',
'#14b8a6',
'#06b6d4',
'#0ea5e9',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#d946ef',
'#ec4899',
'#f43f5e',
];
const color = pickDistinctClassColor(usedColors, () => 0.25);
expect(color).toMatch(/^#[0-9a-f]{6}$/);
expect(usedColors).not.toContain(color);
});
});

78
src/lib/classColors.ts Normal file
View File

@@ -0,0 +1,78 @@
const CLASS_COLOR_PALETTE = [
'#ef4444',
'#f97316',
'#f59e0b',
'#eab308',
'#84cc16',
'#22c55e',
'#10b981',
'#14b8a6',
'#06b6d4',
'#0ea5e9',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#d946ef',
'#ec4899',
'#f43f5e',
];
function normalizeHexColor(color: string): string | null {
const raw = color.trim().toLowerCase();
if (/^#[0-9a-f]{6}$/.test(raw)) return raw;
if (/^#[0-9a-f]{3}$/.test(raw)) {
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
}
return null;
}
function hslToHex(hue: number, saturation: number, lightness: number): string {
const normalizedSaturation = saturation / 100;
const normalizedLightness = lightness / 100;
const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
const intermediate = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const match = normalizedLightness - chroma / 2;
const [red, green, blue] = hue < 60
? [chroma, intermediate, 0]
: hue < 120
? [intermediate, chroma, 0]
: hue < 180
? [0, chroma, intermediate]
: hue < 240
? [0, intermediate, chroma]
: hue < 300
? [intermediate, 0, chroma]
: [chroma, 0, intermediate];
return [red, green, blue]
.map((part) => Math.round((part + match) * 255).toString(16).padStart(2, '0'))
.join('')
.replace(/^/, '#');
}
export function pickDistinctClassColor(
existingColors: Array<string | undefined | null>,
random: () => number = Math.random,
): string {
const usedColors = new Set(
existingColors
.map((color) => normalizeHexColor(color || ''))
.filter((color): color is string => Boolean(color)),
);
const paletteCandidates = CLASS_COLOR_PALETTE.filter((color) => !usedColors.has(color));
if (paletteCandidates.length > 0) {
return paletteCandidates[Math.floor(random() * paletteCandidates.length) % paletteCandidates.length];
}
for (let attempt = 0; attempt < 48; attempt += 1) {
const color = hslToHex(Math.floor(random() * 360), 76, 56);
if (!usedColors.has(color)) return color;
}
let fallbackIndex = 0;
while (true) {
const color = hslToHex((fallbackIndex * 137) % 360, 76, 56);
if (!usedColors.has(color)) return color;
fallbackIndex += 1;
}
}