feat(vtk): 色阶编辑器(2D/3D共享)+模板库后端+剖面着色修正+二维数据集足迹

本会话主要交付:
- 色阶配置对话框 1:1 复刻原版(colorLevel/contourLevel/contourLine/colorEditor + colorUtils):
  左三列⚙表格(层级/线形/颜色) + 层级⚙/线形⚙/颜色⚙ 子对话框 + 连续渐变(直方图/读出/min-max/反转)
  + .lvl/.clr 导入导出;文案/校验对齐原版精确 i18n。
- lvl/clr 模板库接真实后端:IColorTemplateRepository + ApiColorTemplateRepository,
  另存/打开/新建色阶/配色方案下拉 经仓储注入 2D(GridDataChartView)与 3D(主对话框)。
- 剖面帘面着色对齐原版 threeContour.js getTerrainColor:上界 stop 取色 + 满 RGB,
  修正"色带整体下移一格 / 发浅发灰 / 丢 alpha"导致与原版差异大的问题。
- 二维数据集视图首切片:勾选轨迹类数据集 → 足迹平铺进 View3D 地图
  (Api3dRepository::loadMapLine 走 dd/ert/trajectory/line + MapLineActor + col2D 接线),
  view2DMode 控摆放高度,顶/底锚真实地表高程(zRefElev)。
- 测试 252 全绿。

并含本分支前序未提交的 UI 工作(ToastOverlay/TopBar/Theme/DynamicForm/若干 panel),
经 CMakeLists/main.cpp 纠缠,随此 checkpoint 一并提交。未纳入未跟踪的 png/yml 及审查报告 txt。
This commit is contained in:
gaozheng 2026-06-22 12:48:45 +08:00
parent b3b030767d
commit 5e60446210
72 changed files with 4518 additions and 223 deletions

View File

@ -394,6 +394,93 @@
## 7. 表单、表格、对话框等通用组件(补充 · 原型未全部出现但客户端必备) ## 7. 表单、表格、对话框等通用组件(补充 · 原型未全部出现但客户端必备)
### 7.0 表单布局(编辑态 / 只读态)— 总则
> 本节是「编辑/只读表单」的**单一事实来源**,统一约束四类表单:①弹出编辑/新建对话框、②属性子视图(对象属性 / 数据集属性)、③动态表单(`getDynamicForm` 驱动)、④首选项设置。控件本身的样式仍引用 §7.17.3、§6.126.13;本节只定义「把这些控件组织成一张表单」的布局、分组、状态、校验与配色规则。
>
> **设计取向**参考主流优秀客户端macOS 系统设置 / Windows 11 设置(分组卡片 + 左标签、JetBrains IDE 设置密集左标签、Figma 右侧属性面板紧凑内联编辑、Linear / Stripe清晰节奏与即时校验。在「信息密度优先」§0.3)前提下取其**密集左标签 + 清晰分组 + 即时校验 + 克制留白**。
#### 7.0.1 两种表单形态(先定形态,再排布局)
| 形态 | 用途 | 实现 |
|---|---|---|
| **只读表单**(展示) | 纯查看的属性详情 | 用 §6.4 **属性键值表**(键值两列,数值等宽,不可编辑) |
| **可编辑表单**(编辑/新建) | 编辑、新建、设置 | 用本节「标签 + 控件」行式表单 |
**铁律**:一张表单只要存在**任一可编辑字段**,整张表即用「可编辑表单」形态;其中的只读字段以**禁用态控件**§7.1 禁用)呈现,**不得**在同一张表单里一半键值表、一半输入框——保证可编辑与只读字段在同一栅格中对齐一致。
#### 7.0.2 表单栅格与行结构
| 元素 | 规范 |
|---|---|
| 标签位置 | **左侧标签列**(默认,密集专业风),标签文字**右对齐**贴近字段;字段名过长或窄单列对话框可改顶部标签 |
| 标签列宽 | 默认 `96120px`,同一表单内**等宽对齐**(纯只读键值表用 §6.4 的 `72px` |
| 标签 ↔ 字段间距 | `space/md`(12) |
| 字段控件高 | `28px`§7.1);行与行垂直间距 `space/sm`(8) |
| 字段宽度 | 宽面板/对话框中**不要拉满**,单字段最大宽约 `360px`(多行/长文本可更宽);窄属性面板中填充可用宽度 |
| 表单内边距 | 对话框内 `space/xl`(24);面板内 `space/lg`(1216) |
| 列数 | **单列优先**;信息多用分组而非多列。仅「短字段(数值+单位)」可两列并排 |
#### 7.0.3 分组Section
- 分组标题:`text/heading`,上方留 `space/lg`,标题下可加 1px `divider` 贯通。
- 组**内**字段紧凑(行距 `space/sm`),组**间**留 `space/lg``space/xl`。
- 对话框内多组(对齐原版 `getDynamicForm` 的 基本信息 / 测线布设 / 数据质量 等):组数多时用**锚点分组**或**分页签**,避免一屏堆叠过长。
#### 7.0.4 字段状态与标记
| 标记/态 | 规范 |
|---|---|
| **必填** | 标签后红色 `*``status/danger`),紧贴标签 |
| 可选 | **默认不标记**(保持干净);确需时标签后加 `text/tertiary` 的「(可选)」 |
| **只读/禁用** | §7.1 禁用态(底 `neutral-50`/Dark `#1A1F26`、文字 `text/disabled`、禁用光标)——明确不可编辑,其值**仍随表单提交** |
| **错误** | §7.1 错误态:描边 `status/danger` + 字段下方 `text/caption` `status/danger` 说明 |
| 帮助/说明 | 字段下方 `text/caption` `text/tertiary` 一行(与错误说明**互斥**位置) |
| 单位/前后缀 | 框内右端 `text/tertiary`§7.1 前后缀) |
#### 7.0.5 校验与提交反馈
- **即时校验**:失焦/输入时校验单字段错误就地显示§7.1 错误态),不打断输入。
- **提交校验**:点「保存/确定」校验全表 → 有错则**滚动并聚焦第一个错误字段** + 就地显示错误;可同时在表单顶部用**行内提示**§7.7 inline alert汇总「请完善 N 项」。**不要**只弹一个模糊 toast。
- **脏标记**:表单无用户修改时「保存」按钮**置灰不可点**;任一字段改动后启用(与已落地的对象属性面板一致)。
- **提交中**:主按钮 loadingspinner/禁用)防重复提交;成功后 Toast§7.7+ 关闭或刷新。
#### 7.0.6 弹出编辑 / 新建对话框body
- 外壳遵 §7.5(容器 `radius/lg` + 标题栏 + 底部操作栏。body 即本节表单:内距 `space/xl`、单列左标签、分组按 7.0.3。
- 底部操作栏:**取消(次按钮,左)+ 主操作(确定/保存Primary**;破坏性确认用 Danger§7.5/§7.6)。
- **新建 vs 编辑**:编辑态预填现值;不可改字段(如类型、按 API 只读的名称)用**禁用控件**而非省略;标题区分「新建 XX / 编辑 XX」。
#### 7.0.7 四类表单的归口(落地映射)
| 表单 | 形态 | 要点 |
|---|---|---|
| 对象属性面板(对象属性 Tab | 可编辑表单 | 名称**仅顶部显示一处**且按 API 只读;只读字段禁用灰显;脏标记控制保存 |
| 数据集属性面板 | 上半 §6.4 **只读键值表** + 下半**可编辑描述**(单字段可编辑表单) | 两段职责分明 |
| 动态表单(`getDynamicForm` | 可编辑表单 | 分组 = `formList` 组;控件按 `displayComponentType` 映射§7.1/7.2/7.3/6.12);必填/只读由 `requiredType`/`fieldUseType` 决定7.0.4 |
| 首选项设置§7.10 | 设置型表单变体 | 主从布局 + 设置行(左:标题+说明,右:控件) |
#### 7.0.8 配色与排版令牌(汇总 · 禁硬编码)
| 角色 | token |
|---|---|
| 标签文字 | `text/secondary`(必填 `*``status/danger` |
| 字段值 / 输入文字 | `text/primary`;数值/坐标/编号用等宽 `type::kMonoFamily` |
| 分组标题 | `text/heading` |
| 分隔线 | `divider` |
| 错误(描边+说明) | `status/danger` |
| 只读字段 底/文字 | `neutral-50`Dark `#1A1F26`/ `text/disabled` |
| 字段 背景/边框/focus | 见 §7.1`bg/panel`、`border/default`→`border/strong`→`border/focus` |
#### 7.0.9 可访问性
- 标签与控件**关联**(点击标签聚焦其控件 / buddy
- Tab 焦点顺序自上而下、左到右焦点环可见§10、§12
- **不以颜色为唯一信息**:必填除 `*` 外,校验失败有文字说明;错误态有文字。
- 可点控件最小命中区 ≥ `24×24px`§12
---
### 7.1 输入框Text Input ### 7.1 输入框Text Input
| 状态 | 规范 | | 状态 | 规范 |

View File

@ -70,7 +70,7 @@
- **4c-2 ✅ 已提交(44d31a8)**列表选中异常→VTK 高亮联动(R84,list→VTK)`setSelectedAnomaly`(选中 actor 加粗线宽/点尺寸,其余恢复);anomalyProps_ 改 vtkActor。**反向(VTK点异常→回选列表)未做**(需异常 actor 拾取)。 - **4c-2 ✅ 已提交(44d31a8)**列表选中异常→VTK 高亮联动(R84,list→VTK)`setSelectedAnomaly`(选中 actor 加粗线宽/点尺寸,其余恢复);anomalyProps_ 改 vtkActor。**反向(VTK点异常→回选列表)未做**(需异常 actor 拾取)。
- **4c-3 ✅ 已提交(c83f63a)**:异常属性对话框(R83,双击异常列表项弹只读:名称/类型/标记类型/归属三维体/异常体/顶点世界坐标/备注)`AnomalyPropertiesDialog`。**截图字段:模型/端点均无,不展示**(保存对话框截图为 mock 未持久化)。 - **4c-3 ✅ 已提交(c83f63a)**:异常属性对话框(R83,双击异常列表项弹只读:名称/类型/标记类型/归属三维体/异常体/顶点世界坐标/备注)`AnomalyPropertiesDialog`。**截图字段:模型/端点均无,不展示**(保存对话框截图为 mock 未持久化)。
- **#4 异常功能收口** ✅(4a→4c-3 全做完,编译绿+用户实测通过)。**剩余已知限制**:① 反向(VTK 点异常→回选列表)未做(需异常 actor 拾取);② 单条显隐状态跨 refresh 不持久;③ 全链 mock(三维体/切片端点未就绪),端点就绪后切真实。 - **#4 异常功能收口** ✅(4a→4c-3 全做完,编译绿+用户实测通过)。**剩余已知限制**:① 反向(VTK 点异常→回选列表)未做(需异常 actor 拾取);② 单条显隐状态跨 refresh 不持久;③ 全链 mock(三维体/切片端点未就绪),端点就绪后切真实。
5. 分析栏右键接线:**已完成**(切片 保存/保存为/导出▸/删除 全接;体 切片▸/详情)`colorScaleRequested` 仍占位("色阶开发中")。已移除"显示/隐藏"(勾选即显隐)。 5. 分析栏右键接线:**已完成**(切片 保存/保存为/导出▸/删除 全接;体 切片▸/详情/色阶)`colorScaleRequested` **已接 P1 色阶编辑器**(详见下表)。已移除"显示/隐藏"(勾选即显隐)。
6. 三维体/切片/异常详情:**✅ 全部完成**——异常详情对话框(4c-3);体/切片详情对话框(#6,提交 b97ea68)。形态统一为只读属性对话框,非停靠面板。 6. 三维体/切片/异常详情:**✅ 全部完成**——异常详情对话框(4c-3);体/切片详情对话框(#6,提交 b97ea68)。形态统一为只读属性对话框,非停靠面板。
- **其它小欠项**:三维分析栏完整三级树"对象→三维体→切片"里"对象"根层未套(体目前是顶层);真实色阶编辑。 - **其它小欠项**:三维分析栏完整三级树"对象→三维体→切片"里"对象"根层未套(体目前是顶层);真实色阶编辑。
@ -80,7 +80,7 @@
| 候选 | 性质 | 阻塞 | 要点 | | 候选 | 性质 | 阻塞 | 要点 |
|---|---|---|---| |---|---|---|---|
| ~~**#6 三维体/切片 数据详情**~~ ✅ 已完成 | 功能补全 | 否 | **已做(提交 b97ea68)**:只读属性对话框(非面板,仿异常详情)`VolumePropertiesDialog`/`SlicePropertiesDialog`,右键「数据详情」按 ddCode 分派。体=参数+统计(值域/网格/测点数/范围,仅 loaded 时显);切片=位姿/参数(不含统计,切面网格仓储不持久化)。`Api3dRepository::volumeInfo` getter + `StoredVolume.pointCount` 持久化;接口/LocalSample 零改。设计见 `specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md`。**已知限制**:切片采样分辨率/值域需渲染层回写仓储才有,当前不展示。 | | ~~**#6 三维体/切片 数据详情**~~ ✅ 已完成 | 功能补全 | 否 | **已做(提交 b97ea68)**:只读属性对话框(非面板,仿异常详情)`VolumePropertiesDialog`/`SlicePropertiesDialog`,右键「数据详情」按 ddCode 分派。体=参数+统计(值域/网格/测点数/范围,仅 loaded 时显);切片=位姿/参数(不含统计,切面网格仓储不持久化)。`Api3dRepository::volumeInfo` getter + `StoredVolume.pointCount` 持久化;接口/LocalSample 零改。设计见 `specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md`。**已知限制**:切片采样分辨率/值域需渲染层回写仓储才有,当前不展示。 |
| **真实色阶编辑** | 功能 | 否 | `colorScaleRequested` 现占位("色阶开发中")。做成色阶编辑器,影响 体/切片/帘面 渲染观感。 | | **真实色阶编辑P1P4 ✅ 全期完成2D/3D 共享)** | 功能 | 否 | 编辑器**与数据集视图(2D)共用**,复刻原版四件套(`colorLevel/contourLevel/contourLine/colorEditor.vue` + `colorUtils.js`)。**P1** 主表 `ColorScaleConfigDialog`(层级/颜色 + 新增/删除 + 双击改值/改色)。**P2 层级⚙** `ContourLevelDialog`(normal/log/equalArea + 间隔↔层数双向 + 校验) + 纯算法 `ContourLevels`(6 单测) + `interpColor`(mapColors)。**P2 线形⚙** `ContourLineDialog`(线型/线显/线色/标注显/标注色) + `ContourLineConfig`。**P3 颜色⚙** `ColorGradientDialog` + 自绘 `GradientEditWidget`(可拖拽连续渐变) + 预设/反向/整体透明度 → 按层级位置采样回填。**P4** 文件 IO `ColorScaleIO.{hpp,cpp}`(纯函数 `.lvl`/`.clr` 解析生成,4 单测,与原系统互通):主表 导入/导出=`.lvl`,颜色⚙ 导入/导出=`.clr`。**接入**:① 3D 右键「色阶」→ `VtkSceneController::setVolumeColorScale` 重建体素+切片(用 colorScale 含透明度,mock 持久 `volumeScaleCache_`);② 2D `GridDataChartView`「色阶配置」→ 同编辑器 → 色阶 + 线形/标注到 `ContourPlotItem`。设计见 `specs/2026-06-19-vtk-3d-color-scale-editor-design.md`。**已知边界**:模板库(后端)以文件导入导出替代;`RawDataChartView`「色阶配置」仍占位(原始散点无等值线网格);帘面(源剖面)独立色阶不联动。 |
| **收口提 PR 合 main** | 流程 | 否 | 分支已积大量提交。`git diff main...HEAD` 起草摘要+测试计划,`-u` 推送。注意勿纳未跟踪文件。 | | **收口提 PR 合 main** | 流程 | 否 | 分支已积大量提交。`git diff main...HEAD` 起草摘要+测试计划,`-u` 推送。注意勿纳未跟踪文件。 |
| **三级树 对象→三维体→切片** | 结构打磨 | 否 | `Column3DAnalysis` 体目前是顶层,缺"对象"根层(R 结构)。 | | **三级树 对象→三维体→切片** | 结构打磨 | 否 | `Column3DAnalysis` 体目前是顶层,缺"对象"根层(R 结构)。 |
| **坐标轴 O点/字体弹框** | 打磨 | 否 | main.cpp 内 stub(TODO P4)落实。 | | **坐标轴 O点/字体弹框** | 打磨 | 否 | main.cpp 内 stub(TODO P4)落实。 |

View File

@ -0,0 +1,111 @@
# 三维体/切片 色阶编辑器(复刻原版 web「色阶配置」— 设计
> 关联交接:`docs/superpowers/HANDOFF-vtk-3d.md` §「真实色阶编辑」候选项。
> 原版参考:`commercial-admin/src/views/projectSpace/datasetInfo/components/comm/`
> `colorLevel.vue` 外层 + `colorEditor.vue` 内层 + `contourLevel.vue`/`contourLine.vue` 子弹框 + `colorUtils.js` 算法)。
## 0. 目标与边界
把右键「色阶」占位main.cpp `colorScaleRequested` → "色阶设置开发中")替换为可用的色阶编辑对话框,
1:1 复刻原版 web 数据集详情页「色阶配置」对话框(`colorLevel.vue`)的核心交互,应用后驱动
体素 / 切片 / 帘面 重新着色。
后端 3D 色阶保存接口未就绪 → 持久化先走**会话级 mock**(控制器 `volumeScaleCache_`,再勾选命中缓存)。
## 1. 分期P1 本期)
| 期 | 范围 | 验收 |
|----|------|------|
| **P1 ✅** | 主表编辑器:表格(层级/颜色)+ 新增/删除 + 双击改值/改色 + 确定应用 | 编辑后体/切片即时变色 |
| **P2 ✅(层级⚙)** | 层级⚙(分层方式 normal/log/equalArea + 间隔↔层数联动 + 校验)→ 重算层级分布并按旧色阶插值取色 | 改分层后色带分布变化 |
| **P2 ✅(线形⚙ + 共享)** | 线形⚙(线型/线显/线色/标注显/标注色,复刻 contourLine.vue编辑器做成 **2D 数据集视图 + 3D 共用**,输出 `{colorScale, lineConfig, labelConfig}` | 2D 数据集视图改线色/线型/标注色即时生效 |
| **P3 ✅(颜色⚙)** | 连续渐变画布(自绘 `GradientEditWidget` 替 fabric+d3+ 预设方案 + 反向 + 整体透明度 → 按各层级位置采样回填颜色 | 改渐变/透明度后体素/切片/2D 变色(透明度影响 3D 体素观感) |
| **P4 ✅(文件 IO** | 主表 `.lvl` 导入/导出 + 颜色⚙ `.clr` 导入/导出(纯函数 `ColorScaleIO`与原系统互通4 单测) | 导出再导入 1:1 还原 |
| P4 模板库 | 后端 `.lvl/.clr` 模板「另存/打开」需后端接口,本地无后端 → 暂以文件导入导出替代 | — |
## P2层级⚙落地说明
- **子对话框** `ContourLevelDialog`(复刻 `contourLevel.vue`):分层方式下拉、最大/最小等值线、normal
「间隔数↔层数」双向联动(`isAutoUpdating` 防递归)、对数「次要等值线数」、等积「层数+区间面积(自动)」、
恢复默认、确定校验max>min、间隔>0、层数≤50、对数 max>0/min≥0、等积 count>0
- **纯算法** `ContourLevels.{hpp,cpp}`(无 Qt/VTK`generateContourLevels(params, samples)` 复刻
`colorLevel.vue` case 'level' 的层级重算——normal 等距、log 10 的幂区间细分、equalArea 样本分位
样本不足退化等距线性复刻原版失败兜底。6 个单测覆盖。
- **取色**`ColorScaleConfigDialog::interpColor` 复刻 `colorUtils.js` mapColors在旧断点上连续线性
RGBA 插值给新层级上色;主表「层级⚙」按钮打开子对话框 → 重算 → 重填表,最终「确定」走 P1 应用链路。
- **等积样本**main.cpp 从当前体素 `vtkImageData` 标量抽取传入;无则等积退化线性。
## P2线形⚙ + 共享)落地说明
> 修正:编辑器是**与数据集视图2D共用**的组件。2D `ContourPlotItem` 真画等值线,故 线形⚙ 有渲染
> 落点(先前"3D 无落点"仅对 3D 成立)。
- **子对话框** `ContourLineDialog`(复刻 `contourLine.vue`):线型(实线/虚线)、线显、线色、标注显、标注色。
- **共享输出**`ContourLineConfig {lineShow, lineColor, dashed, labelShow, labelColor}`
`ColorScaleConfigDialog` 加「线形⚙」按钮 + `lineConfig()` getter构造增 `lineInit` 入参。
- **2D 接入**`GridDataChartView`「色阶配置」按钮 → 打开共享编辑器(传 grid 色阶 + `grid.values()`
样本 + 当前 `lineCfg_`)→ 确定后更新色阶/线形配置 + 重建 `ContourPlotItem`
`ContourPlotItem` 新增 `setLineColor/setLineDashed/setLabelColor``draw()` 等值线 pen 取色/虚实、
标注 pen 取色(默认黑实线,未编辑前行为不变)。
- **3D 接入**:右键「色阶」打开同一编辑器,仅消费 `colorScale()`,线形/标注忽略3D 帘面 banded
contour 不画独立线、体素/切片无线)。
- **未接**`RawDataChartView`(原始散点视图,无等值线网格)「色阶配置」仍占位;待真有需求再接。
## P3颜色⚙落地说明
- **自绘渐变控件** `GradientEditWidget`(替代原版 fabric.js+d3 画布):横向渐变条 + 棋盘透明底 +
可拖拽三角手柄;单击条加点(色=该处采样)、拖拽移位、双击改色、选中后 Delete/右键删除(≥2)。
- **`ColorGradientDialog`(颜色⚙)**:渐变控件 + 预设方案(含原版 17 段 GMT + 彩虹/蓝白红/灰度) +
反向(pos→1-pos) + 整体透明度(01 滑块) + `.clr` 导入/导出。
- **回填**:主表「颜色⚙」按钮 → 用当前断点归一化位置(lo..hi→0..1)作渐变初值 → 编辑确定后在新渐变上
按各层级位置连续采样回填颜色(复刻 `mapColors`),整体透明度<1 时覆盖 alpha(复刻 `addAlphaToColor`)。
透明度直接影响 3D 体素/切片观感(alpha 进 LUT/转移函数)。
## P4文件 IO落地说明
- **纯函数** `ColorScaleIO.{hpp,cpp}`(无 Qt`parseLvl/generateLvl`(复刻 colorUtils.js `.lvl` LVL3
格式,行内扫 `R G B A` 令牌定位线色/填充色,免疫 `LStyle` 含空格的列错位) +
`parseClr/generateClr`(复刻 colorEditor.vue `.clr` `ColorMap n 0 6 2` 格式)。4 单测覆盖往返。
- **接入**:主表「导入/导出」按钮 = `.lvl`;颜色⚙「导入/导出」按钮 = `.clr`。文件级互通替代后端模板库
(原版「另存模板/打开模板库」走后端 `saveLvlTemplate/queryClrColorLevel`,本地无后端故省)。
- **校正**:原版 `.clr` 导入读透明度取了行首 token恒为 100实为 bug这里取真实透明度 token。
## 2. P1 数据模型映射
原版表格行 `{level, lineType, color}` ←→ 本工程 `core::ColorScale``(value, Rgba)` 升序断点。
- 原版 `generateColorScale(origincolors, dataMin, dataMax)` 把绝对 level 归一化为 pos本工程
`core::ColorScale` 直接存**绝对值**断点,无需归一化 → 表格「层级」= 绝对数据值,确定时逐行
`addStop(value, rgba)`
- alpha`Rgba.a`0255颜色选择用 `QColorDialog`(启用 alpha 通道)保真。
- 表格按层级**降序**显示(高值在上,对齐竖直色阶条直觉);内部仍升序 addStop。
## 3. P1 UI`ColorScaleConfigDialog`,新增 `src/app/`
- 标题「色阶配置」,模态。
- `QTableWidget` 两列:**层级**(数值,右对齐)|**颜色**(色块单元,整格填充该色)。
- 双击「层级」格 → `QInputDialog::getDouble` 改该断点值;改后重排序并刷新色块插值。
- 双击「颜色」格 → `QColorDialog``ShowAlphaChannel`)改该断点色。
- 按钮:**新增**(在选中行上方插入,值取选中行与上一行中点、色取两端线性插值)、**删除**
(选中行;保留 ≥2 个断点)、**确定** / **取消**
- `colorScale()` getter从表格行装配并返回新的 `core::ColorScale`
## 4. P1 应用链路
`Column3DAnalysis::colorScaleRequested(dsId)` → main.cpp 处理:
1. 校验 dsId 为当前已渲染三维体(`sceneView->currentVolumeDsId()==dsId && hasVolume()`),否则提示
「请先勾选该三维体使其渲染后再编辑色阶」。
2. 用 `sceneView->currentColorScale()` + `currentVmin()/currentVmax()` 打开对话框。
3. 确定 → `sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale())`
新增 `VtkSceneController::setVolumeColorScale(dsId, cs)`
- 更新 `volumeScaleCache_[dsId] = cs`(会话级 mock 持久)。
- 若该体已渲染isChecked + volumeCache 命中):`view_.removeDataset(dsId)` → `view_.addVolume(dsId,
grid, cs)`。`addVolume` 内部置 `currentColorScale_` 并触发 `onVolumeChanged` → InteractionManager
以新色阶重建已勾选**切片**;末尾 `renderIncremental()`
- 帘面源剖面为独立数据集、各自源色阶P1 不联动(仅体+切片,对齐 InteractionManager 单元)。
## 5. 不做YAGNI / 边界)
- 不动 2D `GridDataChartView`/`RawDataChartView` 的「色阶配置」按钮(共享同对话框,留 P 后续接线)。
- 不接后端保存mock不做线形/标注、连续画布、模板 IOP2P4

View File

@ -0,0 +1,134 @@
# 二维数据集视图VTK 2D 渲染)设计 — 调研与取舍
> 日期 2026-06-22。分支 `feat/vtk-3d-view`
> 起因:需求"二维数据集视图 = 筛选勾选对象中、ds 类型显示属性为 2D 的数据集,勾选后显示在 VTK 的 2D 视图"。
> **此面板是客户端独有功能,原版 web 无对应面板**(无可照抄的实现)。
---
## 1. 需求原文
```
筛选对象列表中所勾选的对象中、ds 类型的显示属性为 2D 的数据集
勾选一个或者多个数据集,可显示在 VTK 的 2D 视图
```
经与用户确认「VTK 的 2D 视图」= **2D 数据平铺进当前 3D 地图**(不另开正交视口),由二维数据集栏的 `view2DMode`(关闭 / Z=0 / 顶部 / 底部 / 自定义)+ `customZ` 控制摆放高度,叠在底图(天地图/Google之上多选可叠多张。共享同一 `GeoLocalFrame` 配准。
---
## 2. 关键调研结论:后端无「显示属性」字段(实测 API 确认)
用真实 token 拉了 `项目列表 → 项目结构 → 数据集列表` 三级接口(`http://tenant.geomative.cn/pop-api`
- 数据集列表 `POST /business/dsObject/data/page`**classifyType=3** 为数据,非 2返回的每条 ds
```
ddCode(如 dd_inversion_data)、dsTypeCode("ERT platform inversion data")、
dsTypeId、name(类型名)、dsName、dsClassifyType=3、
properties[](confFieldId→value采集时间/CRS/点数/日期…)、parentId/source*…
```
**没有任何 2D/3D、dimension、display、显示 字段。**
- dataView 用的 `POST /business/projectWorkbench/queryProjectStruct` 只返回树节点type 1/2/3同样无维度。
- `properties` 里的 confField 是采集元数据(时间/坐标系/点数),不是显示属性。
**结论**「ds 类型的显示属性为 2D」**不是后端字段**,只能是**客户端按 `ddCode` 自定义的分类**。桌面端其实已有这张表 —— `Api3dRepository::dimensionOf` / `LocalSample3dRepository::dimensionOf`(标了 TODO它**就是**「显示属性」的实现,只是当前不完整(仅 `dd_trajectory_data`→2D其余 3D/Other
> 即:要做这个需求,等于**客户端定义/补全 ddCode→显示维度 映射**,后端给不了真值。
---
## 3. ddCode 形态台账(取自原版 datasetInfo 的 ddCode→详情组件映射 + 行业语义)
| ddCode | 详情组件 / 含义 | 空间形态 |
|---|---|---|
| dd_trajectory_data | ElectrodeCom 电极/测线 | **线**lat/lon 序列)|
| dd_transient_electromagnetic_trajectory_data | BatteryComMapLineCom 瞬变电磁测线 | **线** |
| dd_radar_channel_trajectory / dd_radar_rtk_trajectory | 雷达通道/RTK 轨迹 | **线** |
| dd_inversion_data | ContourCom 等值面 | **竖直剖面**(沿测线距离 × 深度)|
| dd_ert_measurement_data | ScattersCom 散点 | **拟剖面**(电极对 × 视深,散点)|
| dd_ert_measurement_gr_data | GroundResistanceCom 接地电阻 | 沿线序列 |
| dd_gpr_channel_image / dd_radar / dd_gpr_channel_detail | 雷达图像/成果 | **竖直 B-scan**(沿轨迹 × 时深)|
| dd_grid | DdGridCom 网格 | 网格(**面 or 剖面,依数据而定,存疑**|
| dd_voxel / dd_Structual3D / dd_Property3D | 体素 / 结构 / 属性 3D | **三维体** |
| dd_3d_show | ThreeModelCom 3D 模型 | **三维模型** |
| dd_slice | 切片 | 三维分析 |
---
## 4. 专业 + 用户视角哪些值得作为「2D 渲染到 VTK」业务价值导向不凑功能
中央 VTK 视图是**地理配准的 3D 地图**(地形 + 底图 + 共享 frame。"2D 渲染进去"= 把数据**摆到地图上**。按数据的空间本质分三类:
### 4.1 地理足迹型(线 / 点 / 面)—— 有真业务价值 ✅
- **测线 / 轨迹**dd_trajectory_data、各类 *_trajectory本就是地面上一串 lat/lon = 物理测线/电极布设/雷达轨迹。
- **电极 / 传感器点位**:地理点。
- **真·面状栅格**若存在lat×lon 的面值网格,如地表某物性面)。
**价值**:回答"survey 做在**哪**、多条线/对象**空间怎么排布**、覆盖范围、异常相对地图/地形在**哪**"。对**没有三维体表示的纯 2D 数据(轨迹、点)**,地图平铺是它**唯一**的空间表达。daily 工程价值高。
### 4.2 竖直剖面型(反演剖面 / 雷达 B-scan / 拟剖面)—— 平铺进地图**没有业务价值** ❌
- 这类数据是"距离 × **深度**"。把它**平铺**到水平地图上,等于让"深度"当地图的水平轴 —— **地理上无意义、且误导**
- 它们的正确表达是:
- **3D 视图**里沿测线**立成帘面**(地理竖直切片,已实现);
- **2D 详情面板**`GridDataChartView` / Qwt里**正视读图**(距离 × 深度,地球物理师标准看图方式,已实现)。
- 再把剖面图平铺到地图 = **凑功能**,无增量价值。
### 4.3 三维体型voxel / 结构 / 属性 / 3D 模型)—— 不属于 2D
走 3D 渲染(体素/模型),不在本面板范围。
### 4.4 用户视角佐证
- **野外/项目工程师**:要"我测在哪、线怎么排、异常落在地图哪个位置" → **足迹型**每天用。
- **解释员**剖面要么正视读详情面板、要么看帘面3D**绝不**会想看一张平铺在地图上的剖面。
---
## 5. 建议(结论)
**二维数据集视图的真实业务价值 = 地图上的「平面/足迹层」**:把**本质是平面/线、且没有有意义三维体表示**的数据集,按地理位置摆到 3D 地图上,提供空间上下文与覆盖总览。
- **纳入 2D 渲染(足迹)**:测线/轨迹类(线)、电极/传感器点位(点)、真·面状栅格(面,若有)。
- **不纳入**:反演剖面、雷达 B-scan、拟剖面等**竖直剖面**——它们已有"3D 帘面 + 2D 详情正视图"两条更对的表达,平铺地图无价值且误导。
- **dd_grid 存疑**:需确认其数据到底是"面状栅格"(→ 纳入)还是"剖面网格"(→ 不纳入)。
> 即 `dimensionOf` 的 2D 集合应当是**足迹型**,而非把所有非三维体都塞进来。
### 待产品确认
1. 2D 集合是否就取"足迹型"(测线/轨迹 + 点位 + 面状栅格)?
2. `dd_inversion_data` 是否**坚持不进** 2D 面板(按 §4.2,建议不进;它进 3D 栏渲帘面 + 详情面板正视)?
3. `dd_grid` 的真实数据形态(面 / 剖面)?
---
## 5.1 关键数据路径发现(影响实现规模)
`VtkSceneController` 持**两个仓储**
- `dsRepo_` = **`LocalSampleRepository`(样本数据)**`grid(dsId)=dsRepo_.loadGrid` 只有内置样本网格grid1 等)。**现有 Map2D 的 `addSurveyLine(grid(dsId))` 用的就是样本数据,不是真实后端**,且 Map2D 模式从不激活。
- `sceneRepo_` = **`Api3dRepository`真实后端async**:帘面 `loadSection` / 体 `loadVolume` 走真实 ERT 端点。
**结论**:渲染**真实** 2D 足迹**不能复用样本测线路径**,需在 `Api3dRepository` 加**真实异步足迹加载器**。轨迹线端点已存在:`GET /business/dd/ert/trajectory/line?dsObjectId={id}&frontCrsCode={crs}`(返回经纬度,`ApiDatasetRepository::makeTrajectoryMap` 已在用)。
**首切片范围(可交付、可测)**:先做**轨迹线**足迹(`dd_trajectory_data` 等轨迹类)——端点确定、数据形态确定(线)。点位/面状栅格待其端点与数据形态确认后续做。
## 6. 实现路径(确认 2D 集合后)
1. **分类**:补全 `dimensionOf`ddCode→维度2D 集合 = 足迹型。DsRow 无需新字段(后端无此字段)。
2. **渲染**
- 线(测线/轨迹)→ 复用 `buildSurveyLine` / `addSurveyLine`(已存在),在**当前 3D 场景**画(不切 Map2D 模式)。
- 点(电极/传感器)→ 复用 `ElectrodeActor` / 点 actor。
- 面状栅格(若纳入)→ 收尾 `GridContourActor` 成水平等值面着色复用帘面刚修正的「banded + 上界 stop 取色 + 满 RGB」共享函数。
3. **Z 摆放**`view2DModeChanged`(关闭/Z=0/顶部/底部/自定义)+ `customZChanged` → 控制 2D 层世界 Z。
4. **接线**main.cpp`col2D()->checkedDatasetsChanged` → 新增 `setChecked2DDatasets`Z 模式信号接入。底图已就绪。
5. **配准**:共享 `GeoLocalFrame`,与帘面/测线同系。
---
## 7. 参考 / 实证
- 真实端点base `http://tenant.geomative.cn/pop-api`header `geomativeauthorization: Geomative <token>`
- 项目 `POST /business/my/profile/project/page`
- 结构 `GET /business/projectStruct/queryProjectStruct/{projectId}`
- 数据集 `POST /business/dsObject/data/page`body 含 projectId/structParentId/structParentConfType/`classifyTypeList:[3]`/pageNo/pageSize
- dataView 结构 `POST /business/projectWorkbench/queryProjectStruct`
- 桌面端:`Api3dRepository::dimensionOf`(待补全)、`VtkSceneController`Map2D/View3D + addSurveyLine、`Column2DDataset`(信号 checkedDatasetsChanged / view2DModeChanged / customZChanged 已定义、main.cpp 仅接 basemap
</content>
</invoke>

View File

@ -22,6 +22,7 @@ add_executable(geopro_desktop WIN32
main.cpp main.cpp
Theme.cpp Theme.cpp
TopBar.cpp TopBar.cpp
ToastOverlay.cpp
Glyphs.cpp Glyphs.cpp
PanelHeader.cpp PanelHeader.cpp
Credential.cpp Credential.cpp
@ -67,6 +68,13 @@ add_executable(geopro_desktop WIN32
ExportDatasetDialog.cpp ExportDatasetDialog.cpp
AnomalySaveDialog.cpp AnomalySaveDialog.cpp
AnomalyPropertiesDialog.cpp AnomalyPropertiesDialog.cpp
ColorGradientDialog.cpp
ColorScaleConfigDialog.cpp
ColorScaleIO.cpp
ContourLevelDialog.cpp
ContourLevels.cpp
ContourLineDialog.cpp
GradientEditWidget.cpp
SettingsDialog.cpp SettingsDialog.cpp
SliceExport.cpp SliceExport.cpp
SlicePropertiesDialog.cpp SlicePropertiesDialog.cpp

View File

@ -0,0 +1,435 @@
#include "ColorGradientDialog.hpp"
#include <algorithm>
#include <cmath>
#include <QColorDialog>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFile>
#include <QFileDialog>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QIcon>
#include <QInputDialog>
#include <QJsonArray>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPainter>
#include <QPixmap>
#include <QPointer>
#include <QPushButton>
#include <QRegularExpression>
#include <QSignalBlocker>
#include <QSlider>
#include <QVBoxLayout>
#include "ColorScaleIO.hpp"
#include "repo/IColorTemplateRepository.hpp"
namespace geopro::app {
namespace {
using Stop = GradientEditWidget::Stop;
using geopro::core::Rgba;
Rgba hx(unsigned r, unsigned g, unsigned b) {
return Rgba{static_cast<unsigned char>(r), static_cast<unsigned char>(g),
static_cast<unsigned char>(b), 255};
}
QColor toQ(const Rgba& c) { return QColor(c.r, c.g, c.b, c.a); }
Rgba fromQ(const QColor& c) {
return Rgba{static_cast<unsigned char>(c.red()), static_cast<unsigned char>(c.green()),
static_cast<unsigned char>(c.blue()), static_cast<unsigned char>(c.alpha())};
}
// 解析后端色阶 color 字符串("#RRGGBB" / "rgb(r,g,b)" / "rgba(r,g,b,a)")→ Rgba。
Rgba parseColorString(const QString& s) {
const QString t = s.trimmed();
if (t.startsWith(QLatin1Char('#')) && t.size() >= 7) {
bool ok = false;
const unsigned r = t.mid(1, 2).toUInt(&ok, 16);
const unsigned g = t.mid(3, 2).toUInt(&ok, 16);
const unsigned b = t.mid(5, 2).toUInt(&ok, 16);
return hx(r, g, b);
}
static const QRegularExpression re(
QStringLiteral("rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)"));
const auto m = re.match(t);
if (m.hasMatch())
return hx(m.captured(1).toUInt(), m.captured(2).toUInt(), m.captured(3).toUInt());
return hx(0, 0, 0);
}
// 生成配色方案预览色条(下拉用),复刻 generateColorPreview。
QPixmap previewPixmap(const std::vector<Stop>& stops, int w = 100, int h = 16) {
QPixmap pm(w, h);
pm.fill(Qt::white);
if (stops.size() >= 2) {
QPainter p(&pm);
QLinearGradient grad(0, 0, w, 0);
for (const auto& s : stops) grad.setColorAt(std::clamp(s.pos, 0.0, 1.0), toQ(s.color));
p.fillRect(QRect(0, 0, w, h), grad);
}
return pm;
}
QLabel* rightLabel(const QString& text) {
auto* l = new QLabel(text);
l->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
l->setMinimumWidth(72);
return l;
}
} // namespace
ColorGradientDialog::ColorGradientDialog(const std::vector<Stop>& init, double minValue,
double maxValue, double originMin, double originMax,
std::vector<double> samples, double opacity,
geopro::data::IColorTemplateRepository* tplRepo,
QString projectId, QWidget* parent)
: QDialog(parent),
originMin_(originMin),
originMax_(originMax),
opacity_(opacity),
tplRepo_(tplRepo),
projectId_(std::move(projectId)) {
setWindowTitle(QStringLiteral("色阶编辑器"));
setModal(true);
auto* root = new QVBoxLayout(this);
// ── 顶部两列 gridgrid-template-columns: 50% 50% ──────────────────────
auto* grid = new QGridLayout();
grid->setHorizontalSpacing(12);
grid->setVerticalSpacing(8);
int rowIdx = 0;
// 配色方案(下拉带预览色条)。
schemeCombo_ = new QComboBox(this);
schemeCombo_->setIconSize(QSize(100, 16));
{
auto* cell = new QHBoxLayout();
cell->addWidget(rightLabel(QStringLiteral("配色方案:")));
cell->addWidget(schemeCombo_, 1);
grid->addLayout(cell, rowIdx, 0);
}
// 分布方式disabled, 默认线性)+ 反向。
{
auto* cell = new QHBoxLayout();
cell->addWidget(rightLabel(QStringLiteral("分布方式:")));
auto* distCombo = new QComboBox(this);
distCombo->addItem(QStringLiteral("线性"), QStringLiteral("linear"));
distCombo->addItem(QStringLiteral("对数"), QStringLiteral("log"));
distCombo->setCurrentIndex(0);
distCombo->setEnabled(false);
cell->addWidget(distCombo, 1);
auto* reverseBtn = new QPushButton(QStringLiteral("反转"), this);
cell->addWidget(reverseBtn);
connect(reverseBtn, &QPushButton::clicked, this, [this] { gradient_->reverse(); });
grid->addLayout(cell, rowIdx++, 1);
}
// 数值范围只读originMin~originMax各保留 6 位)。
rangeEdit_ = new QLineEdit(this);
rangeEdit_->setReadOnly(true);
rangeEdit_->setText(QStringLiteral("%1~%2")
.arg(QString::number(originMin_, 'f', 6))
.arg(QString::number(originMax_, 'f', 6)));
{
auto* cell = new QHBoxLayout();
cell->addWidget(rightLabel(QStringLiteral("数值范围:")));
cell->addWidget(rangeEdit_, 1);
grid->addLayout(cell, rowIdx, 0);
}
// 最小值/最大值(可编辑)。
{
auto* cell = new QHBoxLayout();
cell->addWidget(rightLabel(QStringLiteral("最小值:")));
minSpin_ = new QDoubleSpinBox(this);
minSpin_->setDecimals(6);
minSpin_->setRange(-1e12, 1e12);
minSpin_->setValue(minValue);
cell->addWidget(minSpin_, 1);
cell->addWidget(rightLabel(QStringLiteral("最大值:")));
maxSpin_ = new QDoubleSpinBox(this);
maxSpin_->setDecimals(6);
maxSpin_->setRange(-1e12, 1e12);
maxSpin_->setValue(maxValue);
cell->addWidget(maxSpin_, 1);
grid->addLayout(cell, rowIdx++, 1);
}
// 当前数据。
curDataLabel_ = new QLabel(QStringLiteral("-"), this);
{
auto* cell = new QHBoxLayout();
cell->addWidget(rightLabel(QStringLiteral("当前数据值:")));
cell->addWidget(curDataLabel_, 1);
grid->addLayout(cell, rowIdx, 0);
}
// 当前位置。
curPosLabel_ = new QLabel(QStringLiteral("-"), this);
{
auto* cell = new QHBoxLayout();
cell->addWidget(rightLabel(QStringLiteral("当前数据位置:")));
cell->addWidget(curPosLabel_, 1);
grid->addLayout(cell, rowIdx++, 1);
}
// 当前颜色(色块按钮,仅选中手柄时可用)。
curColorBtn_ = new QPushButton(this);
curColorBtn_->setFixedSize(48, 22);
curColorBtn_->setEnabled(false);
{
auto* cell = new QHBoxLayout();
cell->addWidget(rightLabel(QStringLiteral("当前颜色:")));
cell->addWidget(curColorBtn_);
cell->addStretch(1);
grid->addLayout(cell, rowIdx++, 0);
}
root->addLayout(grid);
// ── 渐变画布 ───────────────────────────────────────────────────────────
gradient_ = new GradientEditWidget(this);
gradient_->setMinimumHeight(400);
gradient_->setMinMax(minValue, maxValue);
gradient_->setSamples(std::move(samples));
if (init.size() >= 2) gradient_->setStops(init);
root->addWidget(gradient_);
// ── 整体透明度滑块0~1, step 0.01 ───────────────────────────────────
{
auto* opRow = new QHBoxLayout();
opRow->addWidget(new QLabel(QStringLiteral("整体透明度:")));
opacitySlider_ = new QSlider(Qt::Horizontal, this);
opacitySlider_->setRange(0, 100);
opacitySlider_->setValue(static_cast<int>(opacity_ * 100 + 0.5));
opacityLabel_ = new QLabel(QString::number(opacity_, 'f', 2), this);
opRow->addWidget(opacitySlider_, 1);
opRow->addWidget(opacityLabel_);
root->addLayout(opRow);
}
// ── 底部按钮:左 导入/导出/新建色阶;右 取消/应用 ──────────────────────
{
auto* btns = new QDialogButtonBox(this);
auto* importBtn = btns->addButton(QStringLiteral("导入"), QDialogButtonBox::ActionRole);
auto* exportBtn = btns->addButton(QStringLiteral("导出"), QDialogButtonBox::ActionRole);
newSchemeBtn_ = btns->addButton(QStringLiteral("新建色阶"), QDialogButtonBox::ActionRole);
newSchemeBtn_->setEnabled(tplRepo_ != nullptr && !projectId_.isEmpty());
btns->addButton(QStringLiteral("取消"), QDialogButtonBox::RejectRole);
btns->addButton(QStringLiteral("应用"), QDialogButtonBox::AcceptRole);
root->addWidget(btns);
connect(importBtn, &QPushButton::clicked, this, &ColorGradientDialog::importClr);
connect(exportBtn, &QPushButton::clicked, this, &ColorGradientDialog::exportClr);
connect(newSchemeBtn_, &QPushButton::clicked, this, &ColorGradientDialog::newScheme);
connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject);
}
// ── 信号连接 ───────────────────────────────────────────────────────────
connect(schemeCombo_, QOverload<int>::of(&QComboBox::activated), this,
[this](int i) { applyScheme(i); });
connect(gradient_, &GradientEditWidget::handleSelected, this,
&ColorGradientDialog::onHandleSelected);
connect(gradient_, &GradientEditWidget::selectionCleared, this,
&ColorGradientDialog::onSelectionCleared);
connect(curColorBtn_, &QPushButton::clicked, this, &ColorGradientDialog::pickCurrentColor);
connect(minSpin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
[this](double) { onMinMaxChanged(); });
connect(maxSpin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
[this](double) { onMinMaxChanged(); });
connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) {
opacity_ = v / 100.0;
opacityLabel_->setText(QString::number(opacity_, 'f', 2));
});
// 配色方案:内置预设打底,再异步拉取后端 .clr 列表(若有 api
buildBuiltinSchemes();
reloadSchemeCombo();
if (tplRepo_ != nullptr && !projectId_.isEmpty()) queryClrSchemes();
}
std::vector<GradientEditWidget::Stop> ColorGradientDialog::stops() const {
return gradient_->stops();
}
// ── 配色方案 ─────────────────────────────────────────────────────────────────
void ColorGradientDialog::buildBuiltinSchemes() {
schemes_.clear();
// 默认 GMT 17 档(与 colorEditor.vue defaultColorScale 一致)。
schemes_.push_back(
{QStringLiteral("默认 (GMT)"),
{{0.0, hx(0x00, 0x00, 0xAA)}, {0.0625, hx(0x00, 0x00, 0xD3)},
{0.125, hx(0x00, 0x00, 0xFF)}, {0.1875, hx(0x00, 0x80, 0xFF)},
{0.25, hx(0x00, 0xFF, 0xFF)}, {0.3125, hx(0x00, 0xC0, 0x80)},
{0.375, hx(0x00, 0xFF, 0x00)}, {0.4375, hx(0x00, 0x80, 0x00)},
{0.5, hx(0x80, 0xC0, 0x00)}, {0.5625, hx(0xFF, 0xFF, 0x00)},
{0.625, hx(0xBF, 0x80, 0x00)}, {0.6875, hx(0xFF, 0x80, 0x00)},
{0.75, hx(0xFF, 0x00, 0x00)}, {0.8125, hx(0xD3, 0x00, 0x00)},
{0.875, hx(0x84, 0x00, 0x40)}, {0.9375, hx(0x60, 0x00, 0x45)},
{1.0, hx(0x30, 0x00, 0x30)}}});
schemes_.push_back({QStringLiteral("彩虹"),
{{0.0, hx(0, 0, 255)}, {0.25, hx(0, 255, 255)}, {0.5, hx(0, 255, 0)},
{0.75, hx(255, 255, 0)}, {1.0, hx(255, 0, 0)}}});
schemes_.push_back(
{QStringLiteral("蓝白红"),
{{0.0, hx(0, 0, 255)}, {0.5, hx(255, 255, 255)}, {1.0, hx(255, 0, 0)}}});
schemes_.push_back({QStringLiteral("灰度"), {{0.0, hx(0, 0, 0)}, {1.0, hx(255, 255, 255)}}});
}
void ColorGradientDialog::reloadSchemeCombo() {
const QSignalBlocker block(schemeCombo_);
schemeCombo_->clear();
for (const auto& s : schemes_)
schemeCombo_->addItem(QIcon(previewPixmap(s.stops)), s.name);
}
void ColorGradientDialog::applyScheme(int index) {
if (index < 0 || index >= static_cast<int>(schemes_.size())) return;
gradient_->setStops(schemes_[index].stops);
onSelectionCleared();
}
// ── 当前手柄读出 ─────────────────────────────────────────────────────────────
void ColorGradientDialog::onHandleSelected(const QString& colorHex, const QString& valueText,
const QString& percentText) {
curDataLabel_->setText(valueText);
curPosLabel_->setText(percentText);
curColor_ = parseColorString(colorHex);
curColorBtn_->setEnabled(true);
curColorBtn_->setStyleSheet(
QStringLiteral("background-color: %1;").arg(toQ(curColor_).name()));
}
void ColorGradientDialog::onSelectionCleared() {
curDataLabel_->setText(QStringLiteral("-"));
curPosLabel_->setText(QStringLiteral("-"));
curColorBtn_->setEnabled(false);
curColorBtn_->setStyleSheet(QString());
}
void ColorGradientDialog::onMinMaxChanged() {
gradient_->setMinMax(minSpin_->value(), maxSpin_->value());
}
void ColorGradientDialog::pickCurrentColor() {
if (!gradient_->hasSelection()) return;
const QColor picked =
QColorDialog::getColor(toQ(curColor_), this, QStringLiteral("当前颜色"));
if (!picked.isValid()) return;
curColor_ = fromQ(picked);
curColor_.a = 255;
gradient_->setSelectedColor(curColor_);
curColorBtn_->setStyleSheet(
QStringLiteral("background-color: %1;").arg(toQ(curColor_).name()));
}
// ── .clr 导入/导出(复刻 importColorLevel / explortColorLevel ───────────────
void ColorGradientDialog::importClr() {
const QString path = QFileDialog::getOpenFileName(this, QStringLiteral("导入 .clr"), {},
QStringLiteral("色阶文件 (*.clr)"));
if (path.isEmpty()) return;
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
QMessageBox::warning(this, QStringLiteral("导入"), QStringLiteral("无法打开文件。"));
return;
}
const ClrData clr = parseClr(f.readAll().toStdString());
if (clr.stops.size() < 2) {
QMessageBox::warning(this, QStringLiteral("导入"),
QStringLiteral("文件格式不正确或色阶不足。"));
return;
}
std::vector<Stop> st;
for (const auto& [pos, c] : clr.stops) st.push_back({pos, c});
gradient_->setStops(st);
onSelectionCleared();
opacity_ = clr.opacity;
opacitySlider_->setValue(static_cast<int>(opacity_ * 100 + 0.5));
}
void ColorGradientDialog::exportClr() {
const QString path = QFileDialog::getSaveFileName(this, QStringLiteral("导出 .clr"),
QStringLiteral("色阶配置.clr"),
QStringLiteral("色阶文件 (*.clr)"));
if (path.isEmpty()) return;
ClrData clr;
clr.opacity = opacity_;
for (const auto& s : gradient_->stops()) clr.stops.emplace_back(s.pos, s.color);
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("无法写入文件。"));
return;
}
const std::string out = generateClr(clr);
if (f.write(out.c_str(), static_cast<qint64>(out.size())) < 0)
QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("写入失败。"));
}
// ── 后端接线 ─────────────────────────────────────────────────────────────────
void ColorGradientDialog::newScheme() {
if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
bool ok = false;
const QString name = QInputDialog::getText(this, QStringLiteral("新建色阶"),
QStringLiteral("色阶名称:"), QLineEdit::Normal,
QStringLiteral("默认色阶"), &ok);
if (!ok || name.trimmed().isEmpty()) return;
// 领域装配colorscale 串/位置)留在对话框;仓储只负责传输。
QJsonArray scale;
for (const auto& s : gradient_->stops())
scale.append(QJsonObject{{QStringLiteral("pos"), s.pos},
{QStringLiteral("color"), toQ(s.color).name().toUpper()},
{QStringLiteral("colorId"), QString()}});
const QJsonObject properties{{QStringLiteral("name"), name},
{QStringLiteral("colorscale"), scale}};
QPointer<ColorGradientDialog> self(this);
tplRepo_->newClrScheme(projectId_, properties, [self](bool ok, QString) {
if (!self) return;
if (ok) {
QMessageBox::information(self, QStringLiteral("新建色阶"),
QStringLiteral("保存成功。"));
self->queryClrSchemes(); // 刷新下拉
} else {
QMessageBox::warning(self, QStringLiteral("新建色阶"),
QStringLiteral("保存失败。"));
}
});
}
void ColorGradientDialog::queryClrSchemes() {
if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
QPointer<ColorGradientDialog> self(this);
tplRepo_->listClrSchemes(projectId_, [self](bool ok, QJsonArray arr, QString) {
if (!self || !ok) return;
if (arr.isEmpty()) return;
// 领域解析properties.name/colorscale留在对话框。
self->buildBuiltinSchemes(); // 重置为内置,再追加后端
for (const auto& v : arr) {
const QJsonObject props = v.toObject().value(QStringLiteral("properties")).toObject();
const QString name = props.value(QStringLiteral("name")).toString();
const QJsonArray cs = props.value(QStringLiteral("colorscale")).toArray();
if (name.isEmpty() || cs.size() < 2) continue;
std::vector<Stop> st;
for (const auto& c : cs) {
const QJsonObject o = c.toObject();
st.push_back({o.value(QStringLiteral("pos")).toDouble(),
parseColorString(o.value(QStringLiteral("color")).toString())});
}
if (st.size() >= 2) self->schemes_.push_back({name, std::move(st)});
}
self->reloadSchemeCombo();
});
}
} // namespace geopro::app

View File

@ -0,0 +1,82 @@
#pragma once
#include <vector>
#include <QDialog>
#include <QString>
#include "GradientEditWidget.hpp"
class QComboBox;
class QDoubleSpinBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSlider;
namespace geopro::data {
class IColorTemplateRepository;
}
namespace geopro::app {
// 颜色⚙ 连续色阶编辑对话框1:1 复刻 colorEditor.vue
// 两列 grid(配色方案/分布方式+反向/数值范围/最小值最大值/当前数据·位置·颜色) +
// 渐变画布 GradientEditWidget + 整体透明度滑块 + 导入/导出/新建色阶 + 取消/应用。
// 输出契约保持归一化stops()(升序 pos∈[0,1]) + opacity(),由调用方按层级位置回填颜色。
class ColorGradientDialog : public QDialog {
Q_OBJECT
public:
ColorGradientDialog(const std::vector<GradientEditWidget::Stop>& init,
double minValue, double maxValue, // 当前色阶范围(可编辑)
double originMin, double originMax, // 数据原始范围(数值范围只读+直方图域)
std::vector<double> samples, // 直方图样本
double opacity,
geopro::data::IColorTemplateRepository* tplRepo = nullptr,
QString projectId = {},
QWidget* parent = nullptr);
std::vector<GradientEditWidget::Stop> stops() const; // accept() 后有效,升序
double opacity() const { return opacity_; }
private:
struct Scheme {
QString name;
std::vector<GradientEditWidget::Stop> stops;
};
void buildBuiltinSchemes(); // 内置预设(GMT17 + 彩虹 + 蓝白红 + 灰度)
void reloadSchemeCombo(); // 重填配色方案下拉(含预览色条)
void applyScheme(int index); // 切换配色方案 → updateColorScale
void onHandleSelected(const QString& colorHex, const QString& valueText,
const QString& percentText);
void onSelectionCleared();
void onMinMaxChanged();
void pickCurrentColor(); // 点「当前颜色」色块 → QColorDialog → 改当前手柄色
void importClr();
void exportClr();
void newScheme(); // 新建色阶 → POST /business/clr/colorGradation
void queryClrSchemes(); // 列表 → GET .../queryCLRColorGradation/{projectId}
GradientEditWidget* gradient_ = nullptr;
QComboBox* schemeCombo_ = nullptr;
QLineEdit* rangeEdit_ = nullptr;
QDoubleSpinBox* minSpin_ = nullptr;
QDoubleSpinBox* maxSpin_ = nullptr;
QLabel* curDataLabel_ = nullptr;
QLabel* curPosLabel_ = nullptr;
QPushButton* curColorBtn_ = nullptr;
QSlider* opacitySlider_ = nullptr;
QLabel* opacityLabel_ = nullptr;
QPushButton* newSchemeBtn_ = nullptr;
std::vector<Scheme> schemes_;
geopro::core::Rgba curColor_{255, 255, 255, 255};
double originMin_ = 0.0;
double originMax_ = 100.0;
double opacity_ = 1.0;
geopro::data::IColorTemplateRepository* tplRepo_ = nullptr;
QString projectId_;
};
} // namespace geopro::app

View File

@ -0,0 +1,539 @@
#include "ColorScaleConfigDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility>
#include <QCheckBox>
#include <QColor>
#include <QColorDialog>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout>
#include <QFile>
#include <QFileDialog>
#include <QMessageBox>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QPointer>
#include <QRegularExpression>
#include "ColorGradientDialog.hpp"
#include "ColorScaleIO.hpp"
#include "ContourLevelDialog.hpp"
#include "ContourLevels.hpp"
#include "ContourLineDialog.hpp"
#include "repo/IColorTemplateRepository.hpp"
namespace geopro::app {
namespace {
QColor toQColor(const geopro::core::Rgba& c) {
return QColor(c.r, c.g, c.b, c.a);
}
geopro::core::Rgba fromQColor(const QColor& c) {
return geopro::core::Rgba{static_cast<unsigned char>(c.red()),
static_cast<unsigned char>(c.green()),
static_cast<unsigned char>(c.blue()),
static_cast<unsigned char>(c.alpha())};
}
// 两端按比例 t∈[0,1] 线性插值(含 alpha供「新增」取中间色。
geopro::core::Rgba lerp(const geopro::core::Rgba& a, const geopro::core::Rgba& b, double t) {
auto mix = [t](unsigned char x, unsigned char y) {
return static_cast<unsigned char>(x + (y - x) * t + 0.5);
};
return geopro::core::Rgba{mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b), mix(a.a, b.a)};
}
// core::Rgba → 颜色串:不透明用 #RRGGBB半透明用 rgba(r,g,b,a∈0..1)(与后端 colorBar 互通)。
QString rgbaToCss(const geopro::core::Rgba& c) {
if (c.a >= 255)
return QStringLiteral("#%1%2%3")
.arg(c.r, 2, 16, QLatin1Char('0'))
.arg(c.g, 2, 16, QLatin1Char('0'))
.arg(c.b, 2, 16, QLatin1Char('0'))
.toUpper();
return QStringLiteral("rgba(%1, %2, %3, %4)")
.arg(c.r)
.arg(c.g)
.arg(c.b)
.arg(QString::number(c.a / 255.0, 'g', 3));
}
// 颜色串 → core::Rgba支持 #RRGGBB / #AARRGGBB / rgb()/rgba()alpha 0..1/命名黑。
geopro::core::Rgba parseCssColor(const QString& s) {
const QString t = s.trimmed();
if (t.startsWith('#')) {
const QColor q(t); // QColor 识别 #RRGGBB / #AARRGGBB
if (q.isValid()) return fromQColor(q);
}
static const QRegularExpression re(
QStringLiteral("rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*([0-9.]+))?\\)"),
QRegularExpression::CaseInsensitiveOption);
const auto m = re.match(t);
if (m.hasMatch()) {
const int r = m.captured(1).toInt();
const int g = m.captured(2).toInt();
const int b = m.captured(3).toInt();
double a = m.captured(4).isEmpty() ? 1.0 : m.captured(4).toDouble();
if (a > 1.0) a = a / 255.0; // 容错:偶有 0..255 alpha
return geopro::core::Rgba{static_cast<unsigned char>(r), static_cast<unsigned char>(g),
static_cast<unsigned char>(b),
static_cast<unsigned char>(a * 255.0 + 0.5)};
}
return geopro::core::Rgba{0, 0, 0, 255};
}
} // namespace
ColorScaleConfigDialog::ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin,
double vmax, std::vector<double> samples,
const ContourLineConfig& lineInit,
geopro::data::IColorTemplateRepository* tplRepo,
QString projectId, QWidget* parent)
: QDialog(parent),
vmin_(vmin),
vmax_(vmax),
samples_(std::move(samples)),
lineCfg_(lineInit),
tplRepo_(tplRepo),
projectId_(std::move(projectId)) {
setWindowTitle(QStringLiteral("色阶配置"));
setModal(true);
resize(560, 420);
// 用初始色阶的升序断点填模型;空色阶兜底成 vmin/vmax 两端蓝红。
for (const auto& [value, color] : init.stops()) rows_.push_back({value, color});
if (rows_.empty()) {
rows_.push_back({vmin_, geopro::core::Rgba{0, 0, 255, 255}});
rows_.push_back({vmax_, geopro::core::Rgba{255, 0, 0, 255}});
}
auto* root = new QVBoxLayout(this);
auto* mid = new QHBoxLayout();
root->addLayout(mid, 1);
// 左:三列表格(层级 / 线形 / 颜色),每列表头带 ⚙,点击表头打开对应子对话框。
table_ = new QTableWidget(this);
table_->setColumnCount(3);
table_->setHorizontalHeaderLabels(
{QStringLiteral("层级 ⚙"), QStringLiteral("线形 ⚙"), QStringLiteral("颜色 ⚙")});
table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
table_->horizontalHeader()->setSectionsClickable(true);
table_->verticalHeader()->setVisible(false);
table_->setSortingEnabled(false);
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
table_->setSelectionMode(QAbstractItemView::SingleSelection);
table_->setEditTriggers(QAbstractItemView::NoEditTriggers); // 改值/改色走双击
connect(table_, &QTableWidget::cellDoubleClicked, this,
&ColorScaleConfigDialog::onCellDoubleClicked);
connect(table_->horizontalHeader(), &QHeaderView::sectionClicked, this, [this](int section) {
if (section == 0)
onLevelScheme();
else if (section == 1)
onLineScheme();
else
onColorScheme();
});
mid->addWidget(table_, 1);
// 右:竖排按钮 新增 / 删除 / 另存为 / 导出 / 导入 / 打开(复刻 colorLevel.vue 操作列)。
auto* rightCol = new QVBoxLayout();
auto* btnAdd = new QPushButton(QStringLiteral("新增"), this);
auto* btnDel = new QPushButton(QStringLiteral("删除"), this);
btnSaveOther_ = new QPushButton(QStringLiteral("另存"), this);
auto* btnExport = new QPushButton(QStringLiteral("导出"), this);
auto* btnImport = new QPushButton(QStringLiteral("导入"), this);
btnOpen_ = new QPushButton(QStringLiteral("打开"), this);
connect(btnAdd, &QPushButton::clicked, this, &ColorScaleConfigDialog::onAdd);
connect(btnDel, &QPushButton::clicked, this, &ColorScaleConfigDialog::onRemove);
connect(btnSaveOther_, &QPushButton::clicked, this, &ColorScaleConfigDialog::onSaveOther);
connect(btnExport, &QPushButton::clicked, this, &ColorScaleConfigDialog::onExportLvl);
connect(btnImport, &QPushButton::clicked, this, &ColorScaleConfigDialog::onImportLvl);
connect(btnOpen_, &QPushButton::clicked, this, &ColorScaleConfigDialog::onOpen);
for (auto* b : {btnAdd, btnDel, btnSaveOther_, btnExport, btnImport, btnOpen_})
rightCol->addWidget(b);
rightCol->addStretch();
mid->addLayout(rightCol);
// 「另存为 / 打开」依赖后端 lvl 模板库(走仓储),无仓储/无项目时禁用。
const bool hasBackend = tplRepo_ != nullptr && !projectId_.isEmpty();
btnSaveOther_->setEnabled(hasBackend);
btnOpen_->setEnabled(hasBackend);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用"));
buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(buttons);
rebuildTable();
}
void ColorScaleConfigDialog::rebuildTable() {
const int n = static_cast<int>(rows_.size());
table_->setRowCount(n);
const QString solid = QStringLiteral("——————");
const QString dashed = QStringLiteral("- - - - - - - - -");
const QColor lineQc = toQColor(lineCfg_.lineColor);
// 升序显示:低值在上(复刻原版 tableData 自然数组序)。
for (int r = 0; r < n; ++r) {
const Row& row = rows_[static_cast<std::size_t>(r)];
auto* valItem = new QTableWidgetItem(QString::number(row.value, 'g', 6));
valItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
table_->setItem(r, 0, valItem);
auto* lineItem = new QTableWidgetItem(lineCfg_.dashed ? dashed : solid);
lineItem->setForeground(lineQc);
lineItem->setTextAlignment(Qt::AlignCenter);
table_->setItem(r, 1, lineItem);
auto* colItem = new QTableWidgetItem();
colItem->setBackground(toQColor(row.color));
table_->setItem(r, 2, colItem);
}
}
int ColorScaleConfigDialog::selectedModelIndex() const {
const int r = table_->currentRow();
if (r < 0 || r >= static_cast<int>(rows_.size())) return -1;
return r; // 升序显示,行号即模型下标
}
void ColorScaleConfigDialog::onCellDoubleClicked(int row, int col) {
if (row < 0 || row >= static_cast<int>(rows_.size())) return;
const int idx = row;
if (col == 0) { // 改层级值(复刻 handleLevelDblClick
bool ok = false;
const double v = QInputDialog::getDouble(this, QStringLiteral("修改层级值"),
QStringLiteral("数据值"), rows_[idx].value, -1e12,
1e12, 6, &ok);
if (!ok) return;
const geopro::core::Rgba color = rows_[idx].color;
rows_[idx].value = v;
std::sort(rows_.begin(), rows_.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
rebuildTable();
for (int i = 0; i < static_cast<int>(rows_.size()); ++i) {
const Row& ri = rows_[static_cast<std::size_t>(i)];
if (ri.value == v && ri.color.r == color.r && ri.color.g == color.g &&
ri.color.b == color.b && ri.color.a == color.a) {
table_->selectRow(i);
break;
}
}
} else if (col == 2) { // 改颜色(复刻 handleColorDblClick
const QColor cur = toQColor(rows_[idx].color);
const QColor picked = QColorDialog::getColor(cur, this, QStringLiteral("选择颜色"),
QColorDialog::ShowAlphaChannel);
if (!picked.isValid()) return;
rows_[idx].color = fromQColor(picked);
rebuildTable();
table_->selectRow(row);
}
// 线形列col==1双击无动作复刻原版线形改动走表头 ⚙)。
}
void ColorScaleConfigDialog::onAdd() {
// 复刻 handleAdd选中行上方插入中点断点未选中则提示。
const int idx = selectedModelIndex();
if (idx < 0) {
QMessageBox::warning(this, QStringLiteral("新增"), QStringLiteral("请先选择要插入的行。"));
return;
}
const Row& sel = rows_[static_cast<std::size_t>(idx)];
double newLevel = sel.value;
if (idx > 0) // 升序:上一行(idx-1)为更低值,取两者中点
newLevel = (rows_[static_cast<std::size_t>(idx - 1)].value + sel.value) / 2.0;
rows_.insert(rows_.begin() + idx, Row{newLevel, sel.color});
rebuildTable();
table_->selectRow(idx); // 选中新插入行
}
void ColorScaleConfigDialog::onRemove() {
// 复刻 handleDelete未选中提示至少保留 2 行。
const int idx = selectedModelIndex();
if (idx < 0) {
QMessageBox::warning(this, QStringLiteral("删除"), QStringLiteral("请先选择要删除的行。"));
return;
}
if (rows_.size() <= 2) {
QMessageBox::warning(this, QStringLiteral("删除"), QStringLiteral("至少需要保留两行数据。"));
return;
}
rows_.erase(rows_.begin() + idx);
rebuildTable();
table_->clearSelection(); // 复刻 handleDelete删除后清空选中
}
geopro::core::Rgba ColorScaleConfigDialog::interpColor(double value) const {
// 复刻 colorUtils.js mapColors升序断点上钳位 + 找区间 + 线性 RGBA 插值。
if (rows_.empty()) return geopro::core::Rgba{0, 0, 0, 255};
const double ysMin = rows_.front().value, ysMax = rows_.back().value;
if (value <= ysMin) return rows_.front().color;
if (value >= ysMax) return rows_.back().color;
std::size_t i = 0;
while (i + 1 < rows_.size() && value > rows_[i + 1].value) ++i;
const double x0 = rows_[i].value, x1 = rows_[i + 1].value;
const double ratio = (x1 > x0) ? (value - x0) / (x1 - x0) : 0.0;
return lerp(rows_[i].color, rows_[i + 1].color, ratio);
}
void ColorScaleConfigDialog::onLevelScheme() {
// 由当前断点推导 contourLevel 初值(复刻 colorLevel.vue case 'level')。
ContourLevelParams init;
init.method = ContourLevelParams::Method::Normal;
if (lvlSchemeType_ == QStringLiteral("logarithmic"))
init.method = ContourLevelParams::Method::Logarithmic;
else if (lvlSchemeType_ == QStringLiteral("equalArea"))
init.method = ContourLevelParams::Method::EqualArea;
init.minValue = rows_.front().value;
init.maxValue = rows_.back().value;
init.layerCount = static_cast<int>(rows_.size());
init.interval =
(rows_.size() >= 2) ? std::abs(rows_[1].value - rows_[0].value) : (vmax_ - vmin_);
init.logLinesCount = logLinesCount_;
init.equalAreaLayerCount = equalAreaLayerCount_;
const double totalArea =
samples_.empty() ? 1000.0 : static_cast<double>(samples_.size()); // 等积「区间面积」分母
ContourLevelDialog dlg(init, vmin_, vmax_, totalArea, this);
if (dlg.exec() != QDialog::Accepted) return;
const ContourLevelParams p = dlg.params();
// 记录方案字段(另存为 properties 透传,复刻原版)。
switch (p.method) {
case ContourLevelParams::Method::Logarithmic:
lvlSchemeType_ = QStringLiteral("logarithmic");
break;
case ContourLevelParams::Method::EqualArea:
lvlSchemeType_ = QStringLiteral("equalArea");
break;
default:
lvlSchemeType_ = QStringLiteral("normal");
break;
}
logLinesCount_ = p.logLinesCount;
equalAreaLayerCount_ = p.equalAreaLayerCount;
// 1) 按分层方式生成新层级(纯算法)。 2) 旧色阶上插值取色mapColors重建表。
const std::vector<double> levels = generateContourLevels(p, samples_);
std::vector<Row> next;
next.reserve(levels.size());
for (double lv : levels) next.push_back({lv, interpColor(lv)});
if (next.size() < 2) return; // 退化保护
std::sort(next.begin(), next.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
rows_ = std::move(next);
rebuildTable();
}
void ColorScaleConfigDialog::onLineScheme() {
ContourLineDialog dlg(lineCfg_, this);
if (dlg.exec() == QDialog::Accepted) {
lineCfg_ = dlg.config();
rebuildTable(); // 线形列文字/颜色随之刷新
}
}
void ColorScaleConfigDialog::onColorScheme() {
if (rows_.size() < 2) return; // 防御front/back
// 用当前断点归一化位置作渐变初值lo..hi → 0..1)。
const double lo = rows_.front().value, hi = rows_.back().value;
const double span = (hi > lo) ? (hi - lo) : 1.0;
std::vector<GradientEditWidget::Stop> seed;
for (const auto& r : rows_) seed.push_back({(r.value - lo) / span, r.color});
ColorGradientDialog dlg(seed, lo, hi, vmin_, vmax_, samples_, 1.0, tplRepo_, projectId_, this);
if (dlg.exec() != QDialog::Accepted) return;
const auto grad = dlg.stops();
if (grad.size() < 2) return;
const double opacity = dlg.opacity();
const unsigned char alpha = static_cast<unsigned char>(opacity * 255.0 + 0.5);
// 在新渐变上按各层级位置连续采样回填颜色(复刻 mapColors + addAlphaToColor 整体透明度)。
auto sampleGrad = [&](double pos) -> geopro::core::Rgba {
if (pos <= grad.front().pos) return grad.front().color;
if (pos >= grad.back().pos) return grad.back().color;
std::size_t i = 0;
while (i + 1 < grad.size() && pos > grad[i + 1].pos) ++i;
const double x0 = grad[i].pos, x1 = grad[i + 1].pos;
const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0;
return lerp(grad[i].color, grad[i + 1].color, t);
};
for (auto& r : rows_) {
geopro::core::Rgba c = sampleGrad((r.value - lo) / span);
if (opacity < 1.0) c.a = alpha; // 整体透明度覆盖 alpha
r.color = c;
}
rebuildTable();
}
void ColorScaleConfigDialog::onImportLvl() {
const QString path = QFileDialog::getOpenFileName(this, QStringLiteral("导入 .lvl"), {},
QStringLiteral("色阶层级文件 (*.lvl)"));
if (path.isEmpty()) return;
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
QMessageBox::warning(this, QStringLiteral("导入"), QStringLiteral("无法打开文件。"));
return;
}
const std::vector<LvlRow> parsed = parseLvl(f.readAll().toStdString());
if (parsed.size() < 2) {
QMessageBox::warning(this, QStringLiteral("导入"),
QStringLiteral("文件格式不正确或层级不足。"));
return;
}
rows_.clear();
for (const auto& lr : parsed) rows_.push_back({lr.level, lr.color});
std::sort(rows_.begin(), rows_.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
lineCfg_.dashed = parsed.front().dashed; // 线形从首行带入
lineCfg_.lineColor = parsed.front().lineColor;
rebuildTable();
}
void ColorScaleConfigDialog::onExportLvl() {
const QString path = QFileDialog::getSaveFileName(this, QStringLiteral("导出 .lvl"),
QStringLiteral("等值线配置.lvl"),
QStringLiteral("色阶层级文件 (*.lvl)"));
if (path.isEmpty()) return;
std::vector<LvlRow> out;
for (const auto& r : rows_)
out.push_back({r.value, r.color, lineCfg_.dashed, lineCfg_.lineColor});
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("无法写入文件。"));
return;
}
const std::string text = generateLvl(out);
if (f.write(text.c_str(), static_cast<qint64>(text.size())) < 0)
QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("写入失败。"));
}
void ColorScaleConfigDialog::loadColorBar(
const std::vector<std::pair<double, geopro::core::Rgba>>& bar) {
if (bar.size() < 2) return;
rows_.clear();
for (const auto& [level, color] : bar) rows_.push_back({level, color});
std::sort(rows_.begin(), rows_.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
rebuildTable();
if (!rows_.empty()) table_->selectRow(0); // 复刻 handleOpen载入后默认选中首行
}
void ColorScaleConfigDialog::onSaveOther() {
if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
bool ok = false;
const QString name = QInputDialog::getText(this, QStringLiteral("另存模板配置"),
QStringLiteral("模板名称:"), QLineEdit::Normal,
QStringLiteral("等值线配置.lvl"), &ok);
if (!ok || name.trimmed().isEmpty()) return;
// 组装 properties复刻 handleSaveOther
QJsonArray colorBar;
for (const auto& r : rows_)
colorBar.append(QJsonArray{QString::number(r.value, 'f', 2), rgbaToCss(r.color)});
QJsonObject lineConfig{{QStringLiteral("showLines"), lineCfg_.lineShow},
{QStringLiteral("color"), rgbaToCss(lineCfg_.lineColor)},
{QStringLiteral("lineType"),
lineCfg_.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}};
QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg_.labelShow},
{QStringLiteral("color"), rgbaToCss(lineCfg_.labelColor)}};
QJsonObject properties{{QStringLiteral("lineConfig"), lineConfig},
{QStringLiteral("labelConfig"), labelConfig},
{QStringLiteral("lvlSchemeType"), lvlSchemeType_},
{QStringLiteral("logLinesCount"), logLinesCount_},
{QStringLiteral("equalAreaLayerCount"), equalAreaLayerCount_},
{QStringLiteral("colorBar"), colorBar}};
// 走仓储传输;回调里用 QPointer 守卫 this模态对话框可能已关
QPointer<ColorScaleConfigDialog> self(this);
tplRepo_->saveLvlTemplate(projectId_, name.trimmed(), properties,
[self](bool ok, QString msg) {
if (!self) return;
if (ok)
QMessageBox::information(self, QStringLiteral("另存"),
QStringLiteral("另存成功。"));
else
QMessageBox::warning(
self, QStringLiteral("另存"),
QStringLiteral("另存失败:%1").arg(msg));
});
}
void ColorScaleConfigDialog::onOpen() {
if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
QPointer<ColorScaleConfigDialog> self(this);
tplRepo_->listLvlTemplates(projectId_, [self](bool ok, QJsonArray list, QString msg) {
if (!self) return;
if (!ok) {
QMessageBox::warning(self, QStringLiteral("打开"),
QStringLiteral("获取色阶列表失败:%1").arg(msg));
return;
}
if (list.isEmpty()) {
QMessageBox::information(self, QStringLiteral("打开"),
QStringLiteral("暂无可用色阶模板。"));
return;
}
QStringList names;
for (const auto& it : list)
names << it.toObject().value(QStringLiteral("templateName")).toString();
bool picked = false;
const QString chosen = QInputDialog::getItem(
self, QStringLiteral("引用色阶"), QStringLiteral("请选择色阶:"), names, 0,
false, &picked);
if (!picked) return;
const int sel = names.indexOf(chosen);
if (sel < 0) return;
const QJsonObject props =
list[sel].toObject().value(QStringLiteral("properties")).toObject();
const QJsonArray colorBar = props.value(QStringLiteral("colorBar")).toArray();
std::vector<std::pair<double, geopro::core::Rgba>> bar;
for (const auto& e : colorBar) {
const QJsonArray pair = e.toArray();
if (pair.size() < 2) continue;
bar.emplace_back(pair[0].toVariant().toDouble(),
parseCssColor(pair[1].toString()));
}
if (bar.size() < 2) {
QMessageBox::warning(self, QStringLiteral("打开"),
QStringLiteral("色阶数据无效。"));
return;
}
// 透传方案字段。
self->lvlSchemeType_ =
props.value(QStringLiteral("lvlSchemeType")).toString(QStringLiteral("normal"));
self->logLinesCount_ =
props.value(QStringLiteral("logLinesCount")).toInt(8);
self->equalAreaLayerCount_ =
props.value(QStringLiteral("equalAreaLayerCount")).toInt(10);
const QJsonObject lc = props.value(QStringLiteral("lineConfig")).toObject();
if (!lc.isEmpty()) {
self->lineCfg_.lineShow = lc.value(QStringLiteral("showLines")).toBool(true);
self->lineCfg_.dashed =
lc.value(QStringLiteral("lineType")).toString() == QStringLiteral("dashed");
self->lineCfg_.lineColor =
parseCssColor(lc.value(QStringLiteral("color")).toString());
}
self->loadColorBar(bar);
});
}
geopro::core::ColorScale ColorScaleConfigDialog::colorScale() const {
geopro::core::ColorScale cs;
for (const auto& row : rows_) cs.addStop(row.value, row.color); // 内部按 value 升序
return cs;
}
} // namespace geopro::app

View File

@ -0,0 +1,86 @@
#pragma once
#include <vector>
#include <QDialog>
#include <QString>
#include "ContourLineDialog.hpp" // ContourLineConfig线形/标注配置,共享输出)
#include "model/ColorScale.hpp"
class QTableWidget;
class QPushButton;
namespace geopro::data {
class IColorTemplateRepository;
}
namespace geopro::app {
// 色阶配置对话框1:1 复刻原版 web「色阶配置」colorLevel.vue
// 左侧三列表格(层级 / 线形 / 颜色),每列表头带 ⚙ 点击打开对应子对话框
// 层级⚙→ContourLevel 重算层级线形⚙→ContourLine 改线形/标注颜色⚙→ColorGradient 连续渐变);
// 右侧竖排按钮 新增 / 删除 / 另存为 / 导出 / 导入 / 打开。
// 内部以 (value,Rgba) 升序断点建模,与 core::ColorScale 一一对应;行按层级升序显示(低值在上)。
// 只负责编辑:确定后由调用方取 colorScale() / lineConfig() 应用到 2D/3D 渲染。
// 「另存为 / 打开」接真实后端lvl 模板库);无 api 时这两个按钮禁用。
class ColorScaleConfigDialog : public QDialog {
Q_OBJECT
public:
// init当前色阶升序断点填表vmin/vmax数据原始范围层级/颜色子对话框 + 新增外推用);
// samples数据原始标量等积分层 + 颜色编辑器直方图用,空则等积退化为线性);
// lineInit线形/标注初值2D 传当前态3D 用默认);
// tplRepo/projectIdlvl 模板库仓储句柄(可空 → 另存为/打开 禁用)。
ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin, double vmax,
std::vector<double> samples = {},
const ContourLineConfig& lineInit = {},
geopro::data::IColorTemplateRepository* tplRepo = nullptr,
QString projectId = {}, QWidget* parent = nullptr);
// 由表格当前断点装配的新色阶(按层级升序 addStop
geopro::core::ColorScale colorScale() const;
// 线形/标注配置(线形⚙ 编辑后2D 消费3D 忽略)。
ContourLineConfig lineConfig() const { return lineCfg_; }
private:
struct Row {
double value;
geopro::core::Rgba color;
};
void rebuildTable(); // 按 rows_ 重填表格(升序显示:低值在上)
void onCellDoubleClicked(int row, int col);
void onAdd(); // 新增:选中行上方插入中点断点(复刻 handleAdd
void onRemove(); // 删除:移除选中断点(保留 ≥2
void onLevelScheme(); // 层级⚙ContourLevelDialog → 重算层级分布
void onLineScheme(); // 线形⚙ContourLineDialog → 更新线形/标注配置
void onColorScheme(); // 颜色⚙ColorGradientDialog 连续渐变 → 按层级位置回填颜色
void onImportLvl(); // 导入 .lvl 文件
void onExportLvl(); // 导出 .lvl 文件
void onSaveOther(); // 另存为:保存命名 lvl 模板到后端
void onOpen(); // 打开:从后端 lvl 模板库选取载入
int selectedModelIndex() const; // 选中表格行 → rows_ 下标(升序,无选中返回 -1
// 在当前断点(升序)上做连续线性 RGBA 插值取色(复刻 colorUtils.js mapColors
geopro::core::Rgba interpColor(double value) const;
// 用模板/打开载入的 colorBar([level,colorString]) 重填 rows_。
void loadColorBar(const std::vector<std::pair<double, geopro::core::Rgba>>& bar);
QTableWidget* table_ = nullptr;
std::vector<Row> rows_; // 始终按 value 升序维护
double vmin_ = 0.0;
double vmax_ = 0.0;
std::vector<double> samples_; // 数据原始标量(等积分层 + 直方图)
ContourLineConfig lineCfg_; // 线形/标注配置(线形⚙ 编辑)
geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; // lvl 模板库仓储(可空)
QString projectId_;
// 随子对话框更新、写入另存为 properties复刻原版透传字段
QString lvlSchemeType_ = QStringLiteral("normal");
int logLinesCount_ = 8;
int equalAreaLayerCount_ = 10;
QPushButton* btnSaveOther_ = nullptr; // 无 api 时禁用
QPushButton* btnOpen_ = nullptr;
};
} // namespace geopro::app

151
src/app/ColorScaleIO.cpp Normal file
View File

@ -0,0 +1,151 @@
#include "ColorScaleIO.hpp"
#include <array>
#include <cmath>
#include <limits>
#include <optional>
#include <regex>
#include <sstream>
#include <string>
namespace geopro::app {
namespace {
using geopro::core::Rgba;
// 外部文件内容不可信stod 对非数字/超界 token 会抛异常,统一兜成 nullopt。
std::optional<double> safeStod(const std::string& s) noexcept {
try {
return std::stod(s);
} catch (...) {
return std::nullopt;
}
}
unsigned char clampByte(double v) {
if (v < 0) v = 0;
if (v > 255) v = 255;
return static_cast<unsigned char>(v + 0.5);
}
std::vector<std::string> splitLines(const std::string& s) {
std::vector<std::string> out;
std::string line;
std::istringstream is(s);
while (std::getline(is, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back(); // CRLF 容错
out.push_back(line);
}
return out;
}
} // namespace
// ── .lvl ────────────────────────────────────────────────────────────────────
std::vector<LvlRow> parseLvl(const std::string& content) {
std::vector<LvlRow> rows;
const auto lines = splitLines(content);
if (lines.empty() || lines[0].rfind("LVL", 0) != 0) return rows; // 头校验
// 行内扫描 "R<r> G<g> B<b>[ A<a>]":第 1 个=线色(LColor),最后 1 个=填充色(FFGColor)。
// 此法对 LStyle 含空格(".1 in. Dash")导致的列错位免疫。正则只编译一次(构造代价高)。
static const std::regex rgbaRe(R"(R(\d+)\s+G(\d+)\s+B(\d+)(?:\s+A(\d+))?)");
static const std::regex numRe(R"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)");
for (std::size_t i = 2; i < lines.size(); ++i) { // 跳过 头 + 列名 两行
const std::string& ln = lines[i];
if (ln.find_first_not_of(" \t") == std::string::npos) continue;
std::smatch m;
if (!std::regex_search(ln, m, numRe)) continue; // 首数字 = level
const auto lvl = safeStod(m.str());
if (!lvl) continue;
LvlRow row;
row.level = *lvl;
row.dashed = ln.find("Dash") != std::string::npos;
std::vector<Rgba> colors;
for (auto it = std::sregex_iterator(ln.begin(), ln.end(), rgbaRe);
it != std::sregex_iterator(); ++it) {
const auto& mm = *it; // R/G/B/A 来自正则 \d+stod 仅超长才异常 → 仍兜底
const auto r = safeStod(mm[1]), gg = safeStod(mm[2]), b = safeStod(mm[3]);
if (!r || !gg || !b) continue;
const unsigned char a =
mm[4].matched && safeStod(mm[4]) ? clampByte(*safeStod(mm[4]))
: static_cast<unsigned char>(255);
colors.push_back(Rgba{clampByte(*r), clampByte(*gg), clampByte(*b), a});
}
if (colors.empty()) continue; // 无可识别颜色 → 跳过该行
row.lineColor = colors.front();
row.color = colors.back(); // 仅 1 个时线色=填充色
rows.push_back(row);
}
return rows;
}
std::string generateLvl(const std::vector<LvlRow>& rows) {
std::ostringstream os;
os.precision(std::numeric_limits<double>::max_digits10); // 层级值全精度往返不截断
os << "LVL3\n'Level Flags LColor LStyle LWidth FVersion FFGColor FBGColor FPattern "
"OffsetX OffsetY ScaleX ScaleY Angle Coverage";
auto rgba = [](const Rgba& c) {
std::ostringstream s;
s << "\"R" << int(c.r) << " G" << int(c.g) << " B" << int(c.b) << " A" << int(c.a) << '"';
return s.str();
};
for (const auto& r : rows) {
os << '\n'
<< r.level << " 1 " << rgba(r.lineColor) << ' '
<< (r.dashed ? "\".1 in. Dash\"" : "\"Solid\"") << " 0 1 " << rgba(r.color)
<< " \"Black\" \"Solid\" 0 0 1 1 0 0";
}
return os.str();
}
// ── .clr ────────────────────────────────────────────────────────────────────
ClrData parseClr(const std::string& content) {
ClrData out;
auto lines = splitLines(content);
if (!lines.empty() && lines.back().empty()) lines.pop_back();
if (lines.size() < 4) return out; // 头 + ≥1 色 + 2 透明度
if (!std::regex_match(lines[0], std::regex(R"(ColorMap\s+\d+\s+\d+\s+\d+\s+\d+\s*)"))) return out;
auto tokens = [](const std::string& s) {
std::vector<std::string> t;
std::istringstream is(s);
std::string w;
while (is >> w) t.push_back(w);
return t;
};
// 颜色行:索引 1 .. size-3末两行为透明度。非数字 token 跳过该行(外部文件不可信)。
for (std::size_t i = 1; i + 2 < lines.size(); ++i) {
const auto t = tokens(lines[i]);
if (t.size() < 4) continue;
const auto pos = safeStod(t[0]), r = safeStod(t[1]), g = safeStod(t[2]), b = safeStod(t[3]);
if (!pos || !r || !g || !b) continue;
out.stops.emplace_back(*pos / 100.0, Rgba{clampByte(*r), clampByte(*g), clampByte(*b),
static_cast<unsigned char>(255)});
}
// 透明度取末行的第 2 个值(原版读 token[0] 实为 bug这里取真实透明度
const auto last = tokens(lines.back());
if (last.size() >= 2)
if (const auto op = safeStod(last[1])) out.opacity = *op / 100.0;
return out;
}
std::string generateClr(const ClrData& data) {
std::ostringstream body;
body.setf(std::ios::fixed);
body.precision(8);
int n = 0;
for (const auto& [pos, c] : data.stops) {
body << pos * 100.0 << ' ' << int(c.r) << ' ' << int(c.g) << ' ' << int(c.b) << " 255\n";
++n;
}
const double op = data.opacity * 100.0;
body << "0.00000000 " << op << '\n' << "100.00000000 " << op;
std::ostringstream os;
os << "ColorMap " << (n + 2) << " 0 6 2\n" << body.str();
return os.str();
}
} // namespace geopro::app

34
src/app/ColorScaleIO.hpp Normal file
View File

@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <utility>
#include <vector>
#include "model/ColorScale.hpp" // core::Rgba
namespace geopro::app {
// .lvl 文件一行(复刻 colorUtils.js parseLvlFile / generateLvlContent
struct LvlRow {
double level = 0.0;
geopro::core::Rgba color{0, 0, 0, 255}; // FFGColor填充色
bool dashed = false; // LStyle: solid / .1 in. Dash
geopro::core::Rgba lineColor{0, 0, 0, 255}; // LColor线色
};
// 解析 .lvl 文本 → 行列表(头部校验失败/空 → 空)。
std::vector<LvlRow> parseLvl(const std::string& content);
// 生成 .lvl 文本LVL3 头 + 每行 14 列),与原系统互通。
std::string generateLvl(const std::vector<LvlRow>& rows);
// .clr 连续色阶(复刻 colorEditor.vue import/export。pos ∈ [0,1] 升序opacity ∈ [0,1]。
struct ClrData {
std::vector<std::pair<double, geopro::core::Rgba>> stops;
double opacity = 1.0;
};
// 解析 .clr 文本(头 `ColorMap n 0 6 2` + `pos*100 r g b a` 行 + 末两行透明度)。失败 → stops 空。
ClrData parseClr(const std::string& content);
// 生成 .clr 文本。
std::string generateClr(const ClrData& data);
} // namespace geopro::app

View File

@ -0,0 +1,254 @@
#include "ContourLevelDialog.hpp"
#include <cmath>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleValidator>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QLocale>
#include <QMessageBox>
#include <QPushButton>
#include <QVBoxLayout>
#include <QWidget>
namespace geopro::app {
namespace {
constexpr int kMaxLayers = 50; // 原版上限:分层层数 ≤ 50
} // namespace
ContourLevelDialog::ContourLevelDialog(const ContourLevelParams& init, double originMin,
double originMax, double totalArea, QWidget* parent)
: QDialog(parent), originMin_(originMin), originMax_(originMax), totalArea_(totalArea) {
setWindowTitle(QStringLiteral("等值线层级"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
root->addLayout(form);
// 数据范围(原始,只读展示)。
form->addRow(QStringLiteral("数据范围"),
new QLabel(QStringLiteral("%1 ~ %2").arg(originMin_).arg(originMax_)));
// 分层方式。
methodCombo_ = new QComboBox(this);
methodCombo_->addItem(QStringLiteral("一般的"), 0);
methodCombo_->addItem(QStringLiteral("对数"), 1);
methodCombo_->addItem(QStringLiteral("等积"), 2);
methodCombo_->setCurrentIndex(static_cast<int>(init.method));
form->addRow(QStringLiteral("分层方式"), methodCombo_);
auto* validator = new QDoubleValidator(this);
validator->setNotation(QDoubleValidator::StandardNotation);
validator->setLocale(QLocale::c()); // 锁定 C locale与 toDouble() 一致,避免中文系统逗号歧义
// 最大/最小等值线equalArea 时整行隐藏)。
minEdit_ = new QLineEdit(QString::number(init.minValue), this);
maxEdit_ = new QLineEdit(QString::number(init.maxValue), this);
minEdit_->setValidator(validator);
maxEdit_->setValidator(validator);
rangeRow_ = new QWidget(this);
auto* rangeForm = new QFormLayout(rangeRow_);
rangeForm->setContentsMargins(0, 0, 0, 0);
rangeForm->addRow(QStringLiteral("最大等值线"), maxEdit_);
rangeForm->addRow(QStringLiteral("最小等值线"), minEdit_);
root->addWidget(rangeRow_);
// normal间隔数 + 层数(双向联动)。
intervalEdit_ = new QLineEdit(QString::number(init.interval), this);
layerCountEdit_ = new QLineEdit(QString::number(init.layerCount), this);
intervalEdit_->setValidator(validator);
normalRow_ = new QWidget(this);
auto* normalForm = new QFormLayout(normalRow_);
normalForm->setContentsMargins(0, 0, 0, 0);
normalForm->addRow(QStringLiteral("数值间隔"), intervalEdit_);
normalForm->addRow(QStringLiteral("层数"), layerCountEdit_);
root->addWidget(normalRow_);
// logarithmic每数量级次要等值线数。
logLinesEdit_ = new QLineEdit(QString::number(init.logLinesCount), this);
logRow_ = new QWidget(this);
auto* logForm = new QFormLayout(logRow_);
logForm->setContentsMargins(0, 0, 0, 0);
logForm->addRow(QStringLiteral("每数量级次要等值线数"), logLinesEdit_);
root->addWidget(logRow_);
// equalArea等积分层层数 + 区间面积(只读,自动算)。
equalAreaCountEdit_ = new QLineEdit(QString::number(init.equalAreaLayerCount), this);
intervalAreaLabel_ = new QLabel(this);
equalAreaRow_ = new QWidget(this);
auto* eaForm = new QFormLayout(equalAreaRow_);
eaForm->setContentsMargins(0, 0, 0, 0);
eaForm->addRow(QStringLiteral("层数"), equalAreaCountEdit_);
eaForm->addRow(QStringLiteral("区间面积"), intervalAreaLabel_);
root->addWidget(equalAreaRow_);
auto* reset = new QPushButton(QStringLiteral("恢复默认值"), this);
auto* resetRow = new QHBoxLayout();
resetRow->addStretch();
resetRow->addWidget(reset);
root->addLayout(resetRow);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用"));
buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
root->addWidget(buttons);
// 联动接线normal 双向equalArea 区间面积随层数)。
connect(methodCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
[this](int) { onMethodChanged(); });
connect(intervalEdit_, &QLineEdit::textEdited, this,
[this](const QString&) { recalcLayerCountFromInterval(); });
connect(layerCountEdit_, &QLineEdit::textEdited, this,
[this](const QString&) { recalcIntervalFromLayerCount(); });
connect(minEdit_, &QLineEdit::textEdited, this,
[this](const QString&) { recalcLayerCountFromInterval(); });
connect(maxEdit_, &QLineEdit::textEdited, this,
[this](const QString&) { recalcLayerCountFromInterval(); });
connect(equalAreaCountEdit_, &QLineEdit::textEdited, this,
[this](const QString&) { updateIntervalArea(); });
connect(reset, &QPushButton::clicked, this, &ContourLevelDialog::onReset);
connect(buttons, &QDialogButtonBox::accepted, this, &ContourLevelDialog::onAccept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
updateIntervalArea();
updateVisibility(); // 初始仅按方式显隐(不重算,保留传入初值)
}
double ContourLevelDialog::parsed(const QLineEdit* e, double fallback) const {
bool ok = false;
const double v = e->text().toDouble(&ok);
return ok ? v : fallback;
}
void ContourLevelDialog::updateVisibility() {
const int m = methodCombo_->currentIndex();
const bool equalArea = (m == 2);
rangeRow_->setVisible(!equalArea); // 最大/最小只在 normal/log 显示
normalRow_->setVisible(m == 0);
logRow_->setVisible(m == 1);
equalAreaRow_->setVisible(equalArea);
adjustSize();
}
void ContourLevelDialog::onMethodChanged() {
updateVisibility();
if (methodCombo_->currentIndex() == 0)
recalcIntervalFromLayerCount(); // 切回一般时按层数刷间隔(复刻 watch
}
void ContourLevelDialog::recalcLayerCountFromInterval() {
if (autoUpdating_ || methodCombo_->currentIndex() != 0) return;
const double mn = parsed(minEdit_, NAN), mx = parsed(maxEdit_, NAN);
const double iv = parsed(intervalEdit_, NAN);
if (std::isfinite(mn) && std::isfinite(mx) && std::isfinite(iv) && iv > 0 && mx > mn) {
autoUpdating_ = true;
layerCountEdit_->setText(QString::number(static_cast<int>(std::ceil((mx - mn) / iv))));
autoUpdating_ = false;
}
}
void ContourLevelDialog::recalcIntervalFromLayerCount() {
if (autoUpdating_ || methodCombo_->currentIndex() != 0) return;
const double mn = parsed(minEdit_, NAN), mx = parsed(maxEdit_, NAN);
bool ok = false;
const int cnt = layerCountEdit_->text().toInt(&ok);
if (std::isfinite(mn) && std::isfinite(mx) && ok && cnt > 0 && mx > mn) {
autoUpdating_ = true;
intervalEdit_->setText(QString::number((mx - mn) / cnt, 'f', 2));
autoUpdating_ = false;
}
}
void ContourLevelDialog::updateIntervalArea() {
bool ok = false;
const int cnt = equalAreaCountEdit_->text().toInt(&ok);
if (ok && cnt > 0)
intervalAreaLabel_->setText(QString::number(totalArea_ / cnt, 'f', 2));
else
intervalAreaLabel_->setText(QStringLiteral("0"));
}
void ContourLevelDialog::onReset() {
// 复刻 handleReset统一回 normal + 原始范围 + 间隔=(max-min)/20。
methodCombo_->setCurrentIndex(0);
minEdit_->setText(QString::number(originMin_));
maxEdit_->setText(QString::number(originMax_));
// 常值场max==min默认间隔取 1避免间隔=0 导致"恢复默认→应用即报错"。
const double span = originMax_ - originMin_;
intervalEdit_->setText(QString::number(span > 0 ? span / 20.0 : 1.0, 'f', 2));
logLinesEdit_->setText(QStringLiteral("8"));
equalAreaCountEdit_->setText(QStringLiteral("10"));
recalcLayerCountFromInterval();
updateIntervalArea();
}
void ContourLevelDialog::onAccept() {
const int m = methodCombo_->currentIndex();
const double mn = parsed(minEdit_, NAN), mx = parsed(maxEdit_, NAN);
if (m != 2 && !(mx > mn)) {
QMessageBox::warning(this, QStringLiteral("等值线层级"),
QStringLiteral("最大值必须大于最小值"));
return;
}
ContourLevelParams r;
r.method = static_cast<ContourLevelParams::Method>(m);
r.minValue = mn;
r.maxValue = mx;
if (m == 0) { // normal
const double iv = parsed(intervalEdit_, NAN);
if (!(iv > 0)) {
QMessageBox::warning(this, QStringLiteral("等值线层级"),
QStringLiteral("数据间隔必须大于0"));
return;
}
const int layers = static_cast<int>(std::ceil((mx - mn) / iv));
if (layers > kMaxLayers) {
QMessageBox::warning(this, QStringLiteral("等值线层级"),
QStringLiteral("数据间隔设置过小分层层数不能超过50"));
return;
}
r.interval = iv;
r.layerCount = layers;
} else if (m == 1) { // logarithmic
bool ok = false;
const int lc = logLinesEdit_->text().toInt(&ok);
if (!ok || lc <= 0) {
QMessageBox::warning(this, QStringLiteral("等值线层级"),
QStringLiteral("每数量级次要等值线数必须大于0"));
return;
}
if (!(mx > 0)) {
QMessageBox::warning(this, QStringLiteral("等值线层级"),
QStringLiteral("对数分层方式下最大等值线必须大于0"));
return;
}
if (mn < 0) {
QMessageBox::warning(this, QStringLiteral("等值线层级"),
QStringLiteral("对数分层方式下最小等值线必须大于0"));
return;
}
r.logLinesCount = lc;
} else { // equalArea
bool ok = false;
const int cnt = equalAreaCountEdit_->text().toInt(&ok);
if (!ok || cnt <= 0) {
QMessageBox::warning(this, QStringLiteral("等值线层级"),
QStringLiteral("层数必须大于0"));
return;
}
r.equalAreaLayerCount = cnt;
}
result_ = r;
accept();
}
} // namespace geopro::app

View File

@ -0,0 +1,58 @@
#pragma once
#include <QDialog>
#include "ContourLevels.hpp" // ContourLevelParams纯数据与算法同源
class QComboBox;
class QLabel;
class QLineEdit;
class QWidget;
namespace geopro::app {
// 层级⚙ 子对话框(复刻 contourLevel.vue分层方式 normal/logarithmic/equalArea
// 最大/最小等值线normal 下「间隔数↔层数」双向联动,对数/等积各自参数,恢复默认,
// 确定时按原版校验max>min、间隔>0、层数≤50、对数 max>0/min≥0、等积 count>0
// 只产出参数;层级的实际重算 + 颜色插值由 ColorScaleConfigDialog 完成。
class ContourLevelDialog : public QDialog {
Q_OBJECT
public:
// init当前层级状态来自主表originMin/Max数据原始范围仅展示+恢复默认用);
// totalArea等积「区间面积」展示用的分母总量3D 体取样本数,无则给原版默认 1000
ContourLevelDialog(const ContourLevelParams& init, double originMin, double originMax,
double totalArea, QWidget* parent = nullptr);
ContourLevelParams params() const { return result_; } // accept() 后有效
private:
void updateVisibility(); // 仅按分层方式显隐各行(不触发重算)
void onMethodChanged(); // 用户切换方式:显隐 + normal 下按层数刷间隔
void recalcLayerCountFromInterval(); // normal层数 = ceil((max-min)/间隔)
void recalcIntervalFromLayerCount(); // normal间隔 = (max-min)/层数
void updateIntervalArea(); // equalArea区间面积 = totalArea/层数
void onReset();
void onAccept(); // 校验后填 result_ 并 accept
double parsed(const QLineEdit* e, double fallback) const;
QComboBox* methodCombo_ = nullptr;
QWidget* rangeRow_ = nullptr; // 最大/最小equalArea 时隐藏)
QLineEdit* minEdit_ = nullptr;
QLineEdit* maxEdit_ = nullptr;
QWidget* normalRow_ = nullptr; // 间隔数 + 层数
QLineEdit* intervalEdit_ = nullptr;
QLineEdit* layerCountEdit_ = nullptr;
QWidget* logRow_ = nullptr; // 对数:次要等值线数
QLineEdit* logLinesEdit_ = nullptr;
QWidget* equalAreaRow_ = nullptr; // 等积:层数 + 区间面积
QLineEdit* equalAreaCountEdit_ = nullptr;
QLabel* intervalAreaLabel_ = nullptr;
double originMin_ = 0.0;
double originMax_ = 1.0;
double totalArea_ = 1000.0;
bool autoUpdating_ = false; // 防双向联动无限递归(复刻 isAutoUpdating
ContourLevelParams result_;
};
} // namespace geopro::app

82
src/app/ContourLevels.cpp Normal file
View File

@ -0,0 +1,82 @@
#include "ContourLevels.hpp"
#include <algorithm>
#include <cmath>
namespace geopro::app {
namespace {
constexpr int kMaxLevels = 100000; // 纯函数自身的 OOM 兜底UI 另有 ≤50 校验)
std::vector<double> normalLevels(double mn, double mx, double interval) {
std::vector<double> levels;
if (!std::isfinite(mn) || !std::isfinite(mx) || !std::isfinite(interval)) return levels;
if (!(interval > 0) || !(mx > mn)) return levels;
const double lend = std::ceil((mx - mn) / interval);
if (lend > kMaxLevels) return levels; // 极小间隔 → 防爆内存
const int len = static_cast<int>(lend);
for (int i = 0; i < len; ++i) levels.push_back(mn + interval * i);
return levels;
}
std::vector<double> logarithmicLevels(double mn, double mx, int logLines) {
std::vector<double> levels;
if (!std::isfinite(mn) || !std::isfinite(mx)) return levels; // 防 +Inf 致死循环
if (logLines <= 0 || !(mx > 0)) return levels;
auto nextPow10 = [](double v) { return std::pow(10.0, std::ceil(std::log10(v))); };
const double A = (mn <= 0) ? 0.1 : std::max(0.1, nextPow10(mn) / 10.0);
const double H = nextPow10(mx);
std::vector<double> powers;
for (double cur = A; cur <= H; cur *= 10.0) powers.push_back(cur);
levels.push_back(mn);
for (std::size_t i = 0; i + 1 < powers.size(); ++i) {
const double start = powers[i], end = powers[i + 1];
const double step = (end - start) / logLines;
for (int j = 1; j < logLines; ++j) {
const double v = start + step * j;
if (v <= mx) levels.push_back(v);
}
if (end <= mx) levels.push_back(end);
}
if (!powers.empty() && powers.back() < mx) levels.push_back(mx);
std::sort(levels.begin(), levels.end());
return levels;
}
std::vector<double> equalAreaLevels(double mn, double mx, int cnt,
const std::vector<double>& samples) {
std::vector<double> levels;
if (cnt <= 0) return levels;
std::vector<double> z;
z.reserve(samples.size());
for (double v : samples)
if (std::isfinite(v)) z.push_back(v);
if (static_cast<int>(z.size()) >= cnt) { // 分位
std::sort(z.begin(), z.end());
const int chunk = static_cast<int>(z.size()) / cnt;
for (int i = 0; i < cnt - 1; ++i)
levels.push_back(
z[static_cast<std::size_t>(std::min(i * chunk, static_cast<int>(z.size()) - 1))]);
if (levels.empty() || levels.back() < z.back()) levels.push_back(z.back());
} else { // 兜底:等距 cnt 段(线性)
const double step = (mx - mn) / cnt;
for (int i = 0; i < cnt; ++i) levels.push_back(mn + step * i);
}
return levels;
}
} // namespace
std::vector<double> generateContourLevels(const ContourLevelParams& p,
const std::vector<double>& samples) {
switch (p.method) {
case ContourLevelParams::Method::Normal:
return normalLevels(p.minValue, p.maxValue, p.interval);
case ContourLevelParams::Method::Logarithmic:
return logarithmicLevels(p.minValue, p.maxValue, p.logLinesCount);
case ContourLevelParams::Method::EqualArea:
return equalAreaLevels(p.minValue, p.maxValue, p.equalAreaLayerCount, samples);
}
return {};
}
} // namespace geopro::app

26
src/app/ContourLevels.hpp Normal file
View File

@ -0,0 +1,26 @@
#pragma once
#include <vector>
namespace geopro::app {
// 层级(分层方式)参数,复刻原版 contourLevel.vue 的 emit。纯数据无 Qt 依赖。
struct ContourLevelParams {
enum class Method { Normal, Logarithmic, EqualArea };
Method method = Method::Normal;
double minValue = 0.0; // 最小等值线normal/log
double maxValue = 1.0; // 最大等值线normal/log
double interval = 0.1; // 间隔数normal
int layerCount = 10; // 层数normal
int logLinesCount = 8; // 每数量级次要等值线数log
int equalAreaLayerCount = 10; // 等积分层层数equalArea
};
// 按分层方式生成升序层级值(复刻 colorLevel.vue case 'level' 的层级重算):
// normal —— len=ceil((max-min)/间隔)levels = min + 间隔*i
// logarithmic —— 10 的幂区间细分(每数量级 logLinesCount 条次要线)
// equalArea —— 原始样本分位;样本不足 cnt 个时退化为等距 cnt 段(复刻原版失败兜底)
// samples 仅 equalArea 用(其余忽略)。返回的颜色由调用方在旧色阶上插值。
std::vector<double> generateContourLevels(const ContourLevelParams& p,
const std::vector<double>& samples);
} // namespace geopro::app

View File

@ -0,0 +1,102 @@
#include "ContourLineDialog.hpp"
#include <QCheckBox>
#include <QColor>
#include <QColorDialog>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QPushButton>
#include <QVBoxLayout>
namespace geopro::app {
namespace {
QColor toQColor(const geopro::core::Rgba& c) { return QColor(c.r, c.g, c.b, c.a); }
geopro::core::Rgba fromQColor(const QColor& c) {
return geopro::core::Rgba{static_cast<unsigned char>(c.red()),
static_cast<unsigned char>(c.green()),
static_cast<unsigned char>(c.blue()),
static_cast<unsigned char>(c.alpha())};
}
} // namespace
ContourLineDialog::ContourLineDialog(const ContourLineConfig& init, QWidget* parent)
: QDialog(parent), cfg_(init) {
setWindowTitle(QStringLiteral("等值线修改"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
root->addLayout(form);
// 复刻 contourLine.vue选项顺序「虚线」在前、「实线」在后。
lineTypeCombo_ = new QComboBox(this);
lineTypeCombo_->addItem(QStringLiteral("- - - - - - - - -"), true); // dashed
lineTypeCombo_->addItem(QStringLiteral("——————"), false); // solid
lineTypeCombo_->setCurrentIndex(cfg_.dashed ? 0 : 1);
form->addRow(QStringLiteral("线形:"), lineTypeCombo_);
lineShowChk_ = new QCheckBox(this);
lineShowChk_->setChecked(cfg_.lineShow);
form->addRow(QStringLiteral("显示线段:"), lineShowChk_);
lineColorBtn_ = new QPushButton(this);
paintSwatch(lineColorBtn_, cfg_.lineColor);
connect(lineColorBtn_, &QPushButton::clicked, this, &ContourLineDialog::pickLineColor);
form->addRow(QStringLiteral("线段颜色:"), lineColorBtn_);
labelShowChk_ = new QCheckBox(this);
labelShowChk_->setChecked(cfg_.labelShow);
form->addRow(QStringLiteral("显示标注:"), labelShowChk_);
labelColorBtn_ = new QPushButton(this);
paintSwatch(labelColorBtn_, cfg_.labelColor);
connect(labelColorBtn_, &QPushButton::clicked, this, &ContourLineDialog::pickLabelColor);
form->addRow(QStringLiteral("标注颜色:"), labelColorBtn_);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用"));
buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
connect(buttons, &QDialogButtonBox::accepted, this, &ContourLineDialog::onAccept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(buttons);
}
void ContourLineDialog::paintSwatch(QPushButton* btn, const geopro::core::Rgba& c) {
const QColor q = toQColor(c);
btn->setText(q.name(QColor::HexArgb));
btn->setStyleSheet(QStringLiteral("background-color: rgba(%1,%2,%3,%4);")
.arg(c.r)
.arg(c.g)
.arg(c.b)
.arg(c.a));
}
void ContourLineDialog::pickLineColor() {
const QColor picked = QColorDialog::getColor(toQColor(cfg_.lineColor), this,
QStringLiteral("线色"),
QColorDialog::ShowAlphaChannel);
if (!picked.isValid()) return;
cfg_.lineColor = fromQColor(picked);
paintSwatch(lineColorBtn_, cfg_.lineColor);
}
void ContourLineDialog::pickLabelColor() {
const QColor picked = QColorDialog::getColor(toQColor(cfg_.labelColor), this,
QStringLiteral("标注色"),
QColorDialog::ShowAlphaChannel);
if (!picked.isValid()) return;
cfg_.labelColor = fromQColor(picked);
paintSwatch(labelColorBtn_, cfg_.labelColor);
}
void ContourLineDialog::onAccept() {
cfg_.dashed = lineTypeCombo_->currentData().toBool();
cfg_.lineShow = lineShowChk_->isChecked();
cfg_.labelShow = labelShowChk_->isChecked();
// 线色/标注色已在 pick* 即时写入 cfg_。
accept();
}
} // namespace geopro::app

View File

@ -0,0 +1,42 @@
#pragma once
#include <QDialog>
#include "model/ColorScale.hpp" // core::Rgba
class QCheckBox;
class QComboBox;
class QPushButton;
namespace geopro::app {
// 线形/标注配置(复刻 contourLine.vue 的 emit。共享2D 等值线渲染消费3D 无等值线,忽略。
struct ContourLineConfig {
bool lineShow = true;
geopro::core::Rgba lineColor{0, 0, 0, 255};
bool dashed = false; // false=实线 solidtrue=虚线 dashed
bool labelShow = true;
geopro::core::Rgba labelColor{0, 0, 0, 255};
};
// 线形⚙ 子对话框(复刻 contourLine.vue线型(实线/虚线)、线显、线色、标注显、标注色。
class ContourLineDialog : public QDialog {
Q_OBJECT
public:
ContourLineDialog(const ContourLineConfig& init, QWidget* parent = nullptr);
ContourLineConfig config() const { return cfg_; } // accept() 后有效
private:
void pickLineColor();
void pickLabelColor();
void paintSwatch(QPushButton* btn, const geopro::core::Rgba& c);
void onAccept();
QComboBox* lineTypeCombo_ = nullptr;
QCheckBox* lineShowChk_ = nullptr;
QPushButton* lineColorBtn_ = nullptr;
QCheckBox* labelShowChk_ = nullptr;
QPushButton* labelColorBtn_ = nullptr;
ContourLineConfig cfg_;
};
} // namespace geopro::app

View File

@ -73,6 +73,14 @@ QString svgPathFor(Glyph t)
return QStringLiteral("<path d='m15 18-6-6 6-6'/>"); return QStringLiteral("<path d='m15 18-6-6 6-6'/>");
case Glyph::ChevronRight: case Glyph::ChevronRight:
return QStringLiteral("<path d='m9 18 6-6-6-6'/>"); return QStringLiteral("<path d='m9 18 6-6-6-6'/>");
case Glyph::WorkArea: // 工区地图定位标记Lucide map-pin
return QStringLiteral(
"<path d='M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 "
"20.193 4 14.993 4 10a8 8 0 0 1 16 0'/><circle cx='12' cy='10' r='3'/>");
case Glyph::SurveyLine: // 测线折线Lucide spline / line-chart 简化)
return QStringLiteral(
"<path d='M3 17 9 11 13 15 21 7'/><circle cx='3' cy='17' r='1.4'/>"
"<circle cx='21' cy='7' r='1.4'/>");
case Glyph::Workspace: case Glyph::Workspace:
return QStringLiteral( return QStringLiteral(
"<rect width='7' height='7' x='3' y='3' rx='1'/>" "<rect width='7' height='7' x='3' y='3' rx='1'/>"

View File

@ -29,6 +29,9 @@ enum class Glyph {
Fullscreen, // 全屏 / 最大化 Fullscreen, // 全屏 / 最大化
ChevronLeft, // 折叠抽屉(向左) ChevronLeft, // 折叠抽屉(向左)
ChevronRight, // 展开抽屉(向右) ChevronRight, // 展开抽屉(向右)
// 对象树类型图标§6.1GS 工区 / TM 测线)
WorkArea, // GS 检测对象(工区,地图定位标记)
SurveyLine, // TM 方法对象(测线,折线)
// 顶部应用栏图标 // 顶部应用栏图标
Workspace, // 工作空间2x2 宫格) Workspace, // 工作空间2x2 宫格)
Folder, // 项目(文件夹) Folder, // 项目(文件夹)

View File

@ -0,0 +1,333 @@
#include "GradientEditWidget.hpp"
#include <algorithm>
#include <cmath>
#include <QKeyEvent>
#include <QLinearGradient>
#include <QMouseEvent>
#include <QPainter>
namespace geopro::app {
namespace {
// ── 复刻 colorLevelConfigurator.js 常量 ──────────────────────────────────────
constexpr double kRectSize = 9.0; // RECT_SIZE 手柄方块边长
const QColor kLineColor(0x1E, 0x1E, 0x1E); // LINE_COLOR 滑轨黑线
constexpr double kLineWidth = 2.0; // LINE_WIDTH
const QColor kHighlightColor(0xF4, 0xF0, 0x65); // HIGHLIGHT_COLOR 选中描边
constexpr double kHighlightStroke = 2.0; // HIGHLIGHT_STROKE_WIDTH
constexpr double kMarginTop = 40.0; // MARGIN.top
constexpr double kHistogramHeight = 100.0; // HISTOGRAM_HEIGHT
const QColor kHistogramColor(0xC0, 0xC5, 0xCF); // HISTOGRAM_COLOR 灰柱
constexpr double kHistMarginTop = 5.0; // HISTOGRAM_MARGIN.top
constexpr double kHistMarginBottom = 5.0; // HISTOGRAM_MARGIN.bottom
constexpr double kColorBarHeight = 20.0; // COLORBAR_HEIGHT
constexpr double kElementsGap = 10.0; // ELEMENTS_GAP
constexpr int kHistogramBins = 80; // 直方图桶数
QColor toQ(const geopro::core::Rgba& c) { return QColor(c.r, c.g, c.b, c.a); }
QString toHex(const geopro::core::Rgba& c) {
return QStringLiteral("#%1%2%3")
.arg(c.r, 2, 16, QLatin1Char('0'))
.arg(c.g, 2, 16, QLatin1Char('0'))
.arg(c.b, 2, 16, QLatin1Char('0'))
.toUpper();
}
} // namespace
GradientEditWidget::GradientEditWidget(QWidget* parent) : QWidget(parent) {
setFocusPolicy(Qt::StrongFocus);
// 默认蓝→红两端,避免空控件。
nodes_ = {{0.0, geopro::core::Rgba{0, 0, 255, 255}, nextId_++},
{1.0, geopro::core::Rgba{255, 0, 0, 255}, nextId_++}};
}
// ── 数据接口 ─────────────────────────────────────────────────────────────────
void GradientEditWidget::setStops(const std::vector<Stop>& stops) {
nodes_.clear();
for (const auto& s : stops)
nodes_.push_back({std::clamp(s.pos, 0.0, 1.0), s.color, nextId_++});
if (nodes_.size() < 2) {
nodes_ = {{0.0, geopro::core::Rgba{0, 0, 255, 255}, nextId_++},
{1.0, geopro::core::Rgba{255, 0, 0, 255}, nextId_++}};
}
sortNodes();
selectedId_ = -1;
update();
}
std::vector<GradientEditWidget::Stop> GradientEditWidget::stops() const {
std::vector<Node> sorted = nodes_;
std::sort(sorted.begin(), sorted.end(),
[](const Node& a, const Node& b) { return a.pos < b.pos; });
std::vector<Stop> out;
out.reserve(sorted.size());
for (const auto& n : sorted) out.push_back({n.pos, n.color});
return out;
}
void GradientEditWidget::reverse() {
// 复刻 reverseColors保持各断点 pos 不变,仅反转颜色序列。
sortNodes();
std::vector<geopro::core::Rgba> cols;
cols.reserve(nodes_.size());
for (const auto& n : nodes_) cols.push_back(n.color);
std::reverse(cols.begin(), cols.end());
for (std::size_t i = 0; i < nodes_.size(); ++i) nodes_[i].color = cols[i];
emitChangedRepaint();
}
void GradientEditWidget::setMinMax(double minValue, double maxValue) {
minValue_ = minValue;
maxValue_ = maxValue;
update(); // 直方图域随之变化
// 选中态读出值也随域刷新。
if (selectedId_ >= 0) {
for (const auto& n : nodes_)
if (n.id == selectedId_) { emitHandleInfo(n); break; }
}
}
void GradientEditWidget::setSamples(std::vector<double> samples) {
samples_ = std::move(samples);
update();
}
void GradientEditWidget::setSelectedColor(const geopro::core::Rgba& color) {
if (selectedId_ < 0) return;
for (auto& n : nodes_)
if (n.id == selectedId_) { n.color = color; break; }
emitChangedRepaint();
}
// ── 几何布局(与 JS 坐标一致) ───────────────────────────────────────────────
double GradientEditWidget::histTop() const { return kMarginTop; }
double GradientEditWidget::colorBarTop() const {
return kMarginTop + kHistogramHeight + kElementsGap;
}
double GradientEditWidget::sliderLineY() const {
return kMarginTop + kHistogramHeight + kElementsGap + kColorBarHeight + kElementsGap;
}
double GradientEditWidget::trackLeft() const { return kRectSize; }
double GradientEditWidget::trackWidth() const {
return std::max(1.0, width() - kRectSize * 2);
}
double GradientEditWidget::posToX(double pos) const { return trackLeft() + pos * trackWidth(); }
double GradientEditWidget::xToPos(double x) const {
return std::clamp((x - trackLeft()) / trackWidth(), 0.0, 1.0);
}
void GradientEditWidget::sortNodes() {
std::sort(nodes_.begin(), nodes_.end(),
[](const Node& a, const Node& b) { return a.pos < b.pos; });
}
void GradientEditWidget::emitChangedRepaint() {
update();
emit changed();
}
void GradientEditWidget::emitHandleInfo(const Node& n) {
const double value = minValue_ + (maxValue_ - minValue_) * n.pos;
emit handleSelected(toHex(n.color), QString::number(value, 'f', 2),
QStringLiteral("%1%").arg(QString::number(n.pos * 100.0, 'f', 1)));
}
// 色带连续插值取色(复刻 d3.scaleLinear domain=pos range=color
geopro::core::Rgba GradientEditWidget::sample(double pos) const {
std::vector<Node> s = nodes_;
std::sort(s.begin(), s.end(), [](const Node& a, const Node& b) { return a.pos < b.pos; });
if (s.empty()) return geopro::core::Rgba{0, 0, 0, 255};
if (pos <= s.front().pos) return s.front().color;
if (pos >= s.back().pos) return s.back().color;
std::size_t i = 0;
while (i + 1 < s.size() && pos > s[i + 1].pos) ++i;
const double x0 = s[i].pos, x1 = s[i + 1].pos;
const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0;
auto mix = [t](unsigned char a, unsigned char b) {
return static_cast<unsigned char>(a + (b - a) * t + 0.5);
};
const auto& c0 = s[i].color;
const auto& c1 = s[i + 1].color;
return geopro::core::Rgba{mix(c0.r, c1.r), mix(c0.g, c1.g), mix(c0.b, c1.b), mix(c0.a, c1.a)};
}
bool GradientEditWidget::isEndpoint(int id) const {
std::vector<Node> s = nodes_;
std::sort(s.begin(), s.end(), [](const Node& a, const Node& b) { return a.pos < b.pos; });
if (s.empty()) return true;
return id == s.front().id || id == s.back().id;
}
bool GradientEditWidget::isOnSliderLine(const QPointF& p) const {
return std::abs(p.y() - sliderLineY()) < 10.0; // 复刻 isOnSliderLine 容差 10
}
int GradientEditWidget::hitHandle(const QPointF& p) const {
// 手柄为落在滑轨线上的 9px 方块;命中判定取方块包围盒。
const double top = sliderLineY() - kRectSize / 2.0;
int best = -1;
double bestDx = kRectSize; // 优先取最近
for (const auto& n : nodes_) {
const double left = posToX(n.pos);
if (p.x() >= left - 1 && p.x() <= left + kRectSize + 1 && p.y() >= top - 1 &&
p.y() <= top + kRectSize + 1) {
const double dx = std::abs(p.x() - (left + kRectSize / 2.0));
if (dx <= bestDx) {
bestDx = dx;
best = n.id;
}
}
}
return best;
}
// ── 绘制 ─────────────────────────────────────────────────────────────────────
void GradientEditWidget::paintEvent(QPaintEvent*) {
QPainter g(this);
g.fillRect(rect(), Qt::white); // backgroundColor #ffffff
const double tLeft = trackLeft();
const double tWidth = trackWidth();
// 1) 直方图80 等宽桶统计 [min,max] 内样本频数,灰柱按最大频数线性缩放。
if (!samples_.empty() && maxValue_ > minValue_) {
std::vector<int> bins(kHistogramBins, 0);
const double span = maxValue_ - minValue_;
for (double v : samples_) {
if (v < minValue_ || v > maxValue_) continue; // 仅统计域内样本
int idx = static_cast<int>((v - minValue_) / span * kHistogramBins);
if (idx >= kHistogramBins) idx = kHistogramBins - 1; // 右端归入末桶
if (idx < 0) idx = 0;
++bins[idx];
}
int maxLen = 0;
for (int c : bins) maxLen = std::max(maxLen, c);
if (maxLen > 0) {
const double binW = tWidth / kHistogramBins;
const double drawH = kHistogramHeight - kHistMarginTop - kHistMarginBottom;
const double baseY = histTop() + kHistogramHeight - kHistMarginBottom;
g.setPen(Qt::NoPen);
g.setBrush(kHistogramColor);
for (int i = 0; i < kHistogramBins; ++i) {
if (bins[i] <= 0) continue;
const double h = drawH * bins[i] / maxLen;
const double x = tLeft + i * binW;
g.drawRect(QRectF(x, baseY - h, std::max(0.0, binW - 1.0), h));
}
}
}
// 2) 色带:按断点 pos 画横向线性渐变。
const QRectF bar(tLeft, colorBarTop(), tWidth, kColorBarHeight);
QLinearGradient grad(bar.left(), 0, bar.right(), 0);
for (const auto& n : nodes_) grad.setColorAt(std::clamp(n.pos, 0.0, 1.0), toQ(n.color));
g.setPen(Qt::NoPen);
g.fillRect(bar, grad);
// 3) 滑轨黑线。
const double ly = sliderLineY();
g.setPen(QPen(kLineColor, kLineWidth));
g.drawLine(QPointF(tLeft, ly), QPointF(tLeft + tWidth, ly));
// 4) 手柄9px 方块落在滑轨线上,填充=断点色;选中描边高亮。
const double top = ly - kRectSize / 2.0;
for (const auto& n : nodes_) {
const QRectF h(posToX(n.pos), top, kRectSize, kRectSize);
g.setBrush(toQ(n.color));
if (n.id == selectedId_)
g.setPen(QPen(kHighlightColor, kHighlightStroke));
else
g.setPen(Qt::NoPen);
g.drawRect(h);
}
}
// ── 交互 ─────────────────────────────────────────────────────────────────────
void GradientEditWidget::mousePressEvent(QMouseEvent* e) {
const QPointF p = e->position();
const int hit = hitHandle(p);
if (e->button() == Qt::RightButton) {
// 右键删除中间手柄(首尾不可删)。
if (hit >= 0 && !isEndpoint(hit) && nodes_.size() > 2) {
nodes_.erase(std::remove_if(nodes_.begin(), nodes_.end(),
[hit](const Node& n) { return n.id == hit; }),
nodes_.end());
selectedId_ = -1;
emit selectionCleared();
emitChangedRepaint();
}
return;
}
if (hit >= 0) {
if (isEndpoint(hit)) { // 首尾锁定:不可选、不可拖
return;
}
selectedId_ = hit;
dragging_ = true;
for (const auto& n : nodes_)
if (n.id == hit) { emitHandleInfo(n); break; }
update();
return;
}
// 点击空白:清除选中(复刻 handleMouseDown else 分支)。
if (selectedId_ >= 0) {
selectedId_ = -1;
emit selectionCleared();
update();
}
}
void GradientEditWidget::mouseDoubleClickEvent(QMouseEvent* e) {
// 双击滑轨线空白处 → 在该位置加断点,色=该处色带采样。
const QPointF p = e->position();
if (!isOnSliderLine(p)) return;
if (hitHandle(p) >= 0) return; // 命中已有手柄不加点
const double pos = xToPos(p.x());
nodes_.push_back({pos, sample(pos), nextId_++});
sortNodes();
emitChangedRepaint();
}
void GradientEditWidget::mouseMoveEvent(QMouseEvent* e) {
if (!dragging_ || selectedId_ < 0) return;
const double pos = xToPos(e->position().x());
for (auto& n : nodes_)
if (n.id == selectedId_) {
n.pos = pos;
emitHandleInfo(n); // 拖动实时发更新
break;
}
update();
emit changed();
}
void GradientEditWidget::mouseReleaseEvent(QMouseEvent*) {
if (dragging_) {
dragging_ = false;
sortNodes();
update();
}
}
void GradientEditWidget::keyPressEvent(QKeyEvent* e) {
if ((e->key() == Qt::Key_Delete || e->key() == Qt::Key_Backspace) && selectedId_ >= 0 &&
!isEndpoint(selectedId_) && nodes_.size() > 2) {
const int sel = selectedId_;
nodes_.erase(std::remove_if(nodes_.begin(), nodes_.end(),
[sel](const Node& n) { return n.id == sel; }),
nodes_.end());
selectedId_ = -1;
emit selectionCleared();
emitChangedRepaint();
return;
}
QWidget::keyPressEvent(e);
}
} // namespace geopro::app

View File

@ -0,0 +1,87 @@
#pragma once
#include <vector>
#include <QString>
#include <QWidget>
#include "model/ColorScale.hpp" // core::Rgba
namespace geopro::app {
// 连续渐变编辑画布1:1 复刻 colorLevelConfigurator.js从上到下依次为
// 顶部留白(40) → 直方图(高100,灰柱) → 间距10 → 色带(高20) → 间距10 →
// 滑轨黑线 → 手柄(9px方块,落在滑轨上)。
// 交互:双击滑轨空白加断点(色=该处采样);拖动中间手柄改位置;选中后 Delete/右键删除;
// 首尾手柄锁定(不可拖/删/选)。断点 pos∈[0,1],导出按 pos 升序。
class GradientEditWidget : public QWidget {
Q_OBJECT
public:
struct Stop {
double pos;
geopro::core::Rgba color;
};
explicit GradientEditWidget(QWidget* parent = nullptr);
void setStops(const std::vector<Stop>& stops); // pos 升序,至少 2 个
std::vector<Stop> stops() const; // 升序导出
void reverse(); // 反向:保持各 pos 不变,仅反转颜色序列
void setMinMax(double minValue, double maxValue); // 直方图域 + 读出域
void setSamples(std::vector<double> samples); // 直方图样本
// 改当前选中手柄颜色(复刻 updateColorById。无选中则无操作。
void setSelectedColor(const geopro::core::Rgba& color);
bool hasSelection() const { return selectedId_ >= 0; }
QSize sizeHint() const override { return QSize(560, 220); }
signals:
void changed();
// 选中/拖动时携带当前手柄信息(复刻 onHandleClick / onHandleMove + getHandleInfo
// colorHex 形如 "#RRGGBB"valueText=值.toFixed(2)percentText="xx.x%"。
void handleSelected(QString colorHex, QString valueText, QString percentText);
void selectionCleared(); // 点击空白清除选中
protected:
void paintEvent(QPaintEvent*) override;
void mousePressEvent(QMouseEvent*) override;
void mouseMoveEvent(QMouseEvent*) override;
void mouseDoubleClickEvent(QMouseEvent*) override;
void mouseReleaseEvent(QMouseEvent*) override;
void keyPressEvent(QKeyEvent*) override;
private:
struct Node {
double pos;
geopro::core::Rgba color;
int id;
};
int hitHandle(const QPointF& p) const; // 命中手柄 id否则 -1
bool isEndpoint(int id) const; // 首尾(按 pos 升序)
bool isOnSliderLine(const QPointF& p) const; // 落在滑轨线附近
double posToX(double pos) const; // 手柄左边缘 x
double xToPos(double x) const;
geopro::core::Rgba sample(double pos) const; // 色带连续插值取色
void sortNodes();
void emitChangedRepaint();
void emitHandleInfo(const Node& n); // 发选中/拖动信息信号
double histTop() const; // 直方图顶 y
double colorBarTop() const;
double sliderLineY() const;
double trackLeft() const; // 轨道左 x = RECT_SIZE
double trackWidth() const; // 轨道宽 = width - RECT_SIZE*2
std::vector<Node> nodes_;
int nextId_ = 0;
int selectedId_ = -1;
bool dragging_ = false;
double minValue_ = 0.0;
double maxValue_ = 100.0;
std::vector<double> samples_;
};
} // namespace geopro::app

View File

@ -122,12 +122,21 @@ void ObjectFormDialog::buildTopFields() {
} }
auto* fl = new QFormLayout(topBox_); auto* fl = new QFormLayout(topBox_);
fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, // body 内距:对话框用 space/xl(24) 环绕表单(规范 §7.0.6)。
geopro::app::space::kLg, 0); fl->setContentsMargins(geopro::app::space::kXxl, geopro::app::space::kXxl,
geopro::app::space::kXxl, 0);
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
fl->setHorizontalSpacing(geopro::app::space::kMd); fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg
fl->setVerticalSpacing(geopro::app::space::kSm); fl->setVerticalSpacing(geopro::app::space::kMd); // 行距 ≈8px与动态表单一致
// 等宽右标签列 + 字段最大宽上限(规范 §7.0.2),与动态表单对齐。
auto addRow = [&](const QString& text, QWidget* field) {
auto* lbl = new QLabel(text, topBox_);
lbl->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kFormLabelCol));
field->setMaximumWidth(geopro::app::scaledPx(geopro::app::space::kFormFieldMax));
fl->addRow(lbl, field);
};
const bool isCreate = objectId_.isEmpty(); const bool isCreate = objectId_.isEmpty();
@ -136,7 +145,7 @@ void ObjectFormDialog::buildTopFields() {
const QString label = const QString label =
confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型"); confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型");
typeCombo_ = new QComboBox(topBox_); typeCombo_ = new QComboBox(topBox_);
fl->addRow(label, typeCombo_); addRow(label, typeCombo_);
QObject::connect(typeCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this, QObject::connect(typeCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) { [this](int) {
const QString tid = typeCombo_->currentData().toString(); const QString tid = typeCombo_->currentData().toString();
@ -150,18 +159,18 @@ void ObjectFormDialog::buildTopFields() {
typeNameLabel_ = new QLabel(topBox_); typeNameLabel_ = new QLabel(topBox_);
geopro::app::applyTokenizedStyleSheet(typeNameLabel_, geopro::app::applyTokenizedStyleSheet(typeNameLabel_,
QStringLiteral("color:{{text/secondary}};")); QStringLiteral("color:{{text/secondary}};"));
fl->addRow(QStringLiteral("类型"), typeNameLabel_); addRow(QStringLiteral("类型"), typeNameLabel_);
} }
nameEdit_ = new QLineEdit(topBox_); nameEdit_ = new QLineEdit(topBox_);
nameEdit_->setPlaceholderText(QStringLiteral("名称")); nameEdit_->setPlaceholderText(QStringLiteral("名称"));
if (!isCreate) nameEdit_->setEnabled(false); // 编辑态名称禁用 if (!isCreate) nameEdit_->setEnabled(false); // 编辑态名称禁用
fl->addRow(QStringLiteral("名称"), nameEdit_); addRow(QStringLiteral("名称"), nameEdit_);
if (confType_ == kConfGs) { if (confType_ == kConfGs) {
responsibleEdit_ = new QLineEdit(topBox_); responsibleEdit_ = new QLineEdit(topBox_);
responsibleEdit_->setPlaceholderText(QStringLiteral("负责人")); responsibleEdit_->setPlaceholderText(QStringLiteral("负责人"));
fl->addRow(QStringLiteral("负责人"), responsibleEdit_); addRow(QStringLiteral("负责人"), responsibleEdit_);
} }
} }
@ -271,11 +280,13 @@ QJsonObject ObjectFormDialog::buildBody() const {
void ObjectFormDialog::onConfirm() { void ObjectFormDialog::onConfirm() {
if (nameEdit_ && nameEdit_->text().trimmed().isEmpty()) { if (nameEdit_ && nameEdit_->text().trimmed().isEmpty()) {
if (nameEdit_->isEnabled()) nameEdit_->setFocus(Qt::OtherFocusReason); // 聚焦名称
QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写名称")); QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写名称"));
return; return;
} }
QString missing; QString missing;
if (!editor_->validateRequired(&missing)) { if (!editor_->validateRequired(&missing)) {
editor_->focusFirstInvalid(); // 规范 §7.0.5:聚焦第一个错误字段
QMessageBox::warning(this, QStringLiteral("校验"), QMessageBox::warning(this, QStringLiteral("校验"),
QStringLiteral("请填写必填项:%1").arg(missing)); QStringLiteral("请填写必填项:%1").arg(missing));
return; return;

View File

@ -16,11 +16,12 @@ namespace geopro::app {
namespace { namespace {
// ── 专业图标/字号尺寸(统一放大)── // ── 表头图标/尺寸(规范 §4.3:高 36px、标题图标 14px、操作按钮 24×24 含 16px 图标)──
constexpr int kHeaderHeight = 42; constexpr int kHeaderHeight = 36; // 表头高度§4.3)。标准/Tab/分段表头共用,保持一致。
constexpr int kTitleIcon = 20; // 表头标题图标 constexpr int kTitleIcon = 14; // 表头标题图标§4.3「14px 图标」)
constexpr int kActionIcon = 19; // 表头操作按钮图标 constexpr int kActionIcon = 16; // 操作按钮内图标§9「按钮内 16px」
constexpr int kTabIcon = 19; // Tab 图标 constexpr int kActionButton = 24; // 操作按钮命中区 24×24§4.3 / §12 无障碍 ≥24×24
constexpr int kTabIcon = 19; // Tab 图标
// 表头统一样式(标准表头 + Tab 表头共用)。字号/字重引用 Theme 排版令牌: // 表头统一样式(标准表头 + Tab 表头共用)。字号/字重引用 Theme 排版令牌:
// 面板标题=title(15)、徽标=caption(12)、Tab 文本=body(13),加粗统一 semibold。 // 面板标题=title(15)、徽标=caption(12)、Tab 文本=body(13),加粗统一 semibold。
@ -37,7 +38,7 @@ QString headerQss()
" padding:1px 7px; font-size:%2px; font-weight:%3; }" " padding:1px 7px; font-size:%2px; font-weight:%3; }"
"#panelBadgeWarn { background:{{status/warning-bg}}; color:{{status/warning}}; border-radius:9px;" "#panelBadgeWarn { background:{{status/warning-bg}}; color:{{status/warning}}; border-radius:9px;"
" padding:1px 7px; font-size:%2px; font-weight:%3; }" " padding:1px 7px; font-size:%2px; font-weight:%3; }"
"QToolButton#panelAction { border:none; border-radius:7px; padding:5px; }" "QToolButton#panelAction { border:none; border-radius:%5px; padding:0px; }"
"QToolButton#panelAction:hover { background:{{bg/hover}}; }" "QToolButton#panelAction:hover { background:{{bg/hover}}; }"
"QToolButton#tabBtn { border:none; border-bottom:2px solid transparent; color:{{text/secondary}};" "QToolButton#tabBtn { border:none; border-bottom:2px solid transparent; color:{{text/secondary}};"
" padding:8px 6px; font-size:%4px; }" " padding:8px 6px; font-size:%4px; }"
@ -47,7 +48,8 @@ QString headerQss()
.arg(scaledPx(type::kTitle)) // %1 标题字号 .arg(scaledPx(type::kTitle)) // %1 标题字号
.arg(scaledPx(type::kCaption)) // %2 徽标字号 .arg(scaledPx(type::kCaption)) // %2 徽标字号
.arg(type::kWeightSemibold) // %3 字重(多处) .arg(type::kWeightSemibold) // %3 字重(多处)
.arg(scaledPx(type::kBody)); // %4 页签字号 .arg(scaledPx(type::kBody)) // %4 页签字号
.arg(radius::kSm); // %5 操作按钮悬停底圆角§3.2
} }
// 数量徽标(默认隐藏,调用方 setText+setVisible 显示)。 // 数量徽标(默认隐藏,调用方 setText+setVisible 显示)。
@ -68,7 +70,9 @@ QWidget* makeActionButton(QWidget* parent, const HeaderAction& a)
btn->setObjectName(QStringLiteral("panelAction")); btn->setObjectName(QStringLiteral("panelAction"));
btn->setProperty("glyphId", static_cast<int>(a.first)); // 供调用方按图标定位并连接真实功能 btn->setProperty("glyphId", static_cast<int>(a.first)); // 供调用方按图标定位并连接真实功能
setThemedGlyph(btn, a.first, kActionIcon); setThemedGlyph(btn, a.first, kActionIcon);
btn->setIconSize(QSize(kActionIcon, kActionIcon)); btn->setIconSize(QSize(scaledPx(kActionIcon), scaledPx(kActionIcon)));
// 命中区固定 24×24§4.3 / §12 无障碍16px 图标居中、四周自然留出内距。
btn->setFixedSize(QSize(scaledPx(kActionButton), scaledPx(kActionButton)));
btn->setCursor(Qt::PointingHandCursor); btn->setCursor(Qt::PointingHandCursor);
btn->setToolTip(a.second + QStringLiteral("(占位)")); btn->setToolTip(a.second + QStringLiteral("(占位)"));
btn->setAutoRaise(true); btn->setAutoRaise(true);
@ -81,7 +85,7 @@ QWidget* buildPanelHeader(Glyph icon, const QString& title, const QVector<Header
{ {
auto* header = new QWidget(); auto* header = new QWidget();
header->setObjectName(QStringLiteral("panelHeader")); header->setObjectName(QStringLiteral("panelHeader"));
header->setFixedHeight(kHeaderHeight); header->setFixedHeight(scaledPx(kHeaderHeight));
geopro::app::applyTokenizedStyleSheet(header, headerQss()); geopro::app::applyTokenizedStyleSheet(header, headerQss());
auto* lay = new QHBoxLayout(header); auto* lay = new QHBoxLayout(header);
@ -114,7 +118,7 @@ TabbedPanel buildTabbedPanel(const QVector<PanelTab>& tabs, const QVector<Header
auto* header = new QWidget(box); auto* header = new QWidget(box);
header->setObjectName(QStringLiteral("panelHeader")); header->setObjectName(QStringLiteral("panelHeader"));
header->setFixedHeight(kHeaderHeight); header->setFixedHeight(scaledPx(kHeaderHeight));
geopro::app::applyTokenizedStyleSheet(header, headerQss()); geopro::app::applyTokenizedStyleSheet(header, headerQss());
auto* hlay = new QHBoxLayout(header); auto* hlay = new QHBoxLayout(header);
hlay->setContentsMargins(10, 0, 8, 0); hlay->setContentsMargins(10, 0, 8, 0);
@ -169,7 +173,7 @@ SegmentedHeader buildSegmentedHeader(const QVector<QString>& segments,
{ {
auto* header = new QWidget(); auto* header = new QWidget();
header->setObjectName(QStringLiteral("panelHeader")); header->setObjectName(QStringLiteral("panelHeader"));
header->setFixedHeight(kHeaderHeight); header->setFixedHeight(scaledPx(kHeaderHeight));
geopro::app::applyTokenizedStyleSheet(header, headerQss()); geopro::app::applyTokenizedStyleSheet(header, headerQss());
auto* hlay = new QHBoxLayout(header); auto* hlay = new QHBoxLayout(header);

View File

@ -2,41 +2,79 @@
#include <QComboBox> #include <QComboBox>
#include <QCoreApplication> #include <QCoreApplication>
#include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QProcess> #include <QProcess>
#include <QPushButton> #include <QPushButton>
#include <QSize>
#include <QStackedWidget> #include <QStackedWidget>
#include <QTextBrowser> #include <QTextBrowser>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include "Glyphs.hpp"
#include "Theme.hpp" #include "Theme.hpp"
namespace geopro::app { namespace geopro::app {
namespace { namespace {
// 「标签 + 控件」一行(标签定宽左对齐,控件右随)。 // 设置项行§7.10):左 = 标题(text/body) + 可选说明(text/caption · text/tertiary) 竖叠;
QWidget* makeRow(const QString& label, QWidget* control) { // 右 = 控件(开关/下拉/输入。caption 为空则只显标题。
QWidget* makeRow(const QString& label, QWidget* control, const QString& caption = QString()) {
auto* row = new QWidget(); auto* row = new QWidget();
auto* lay = new QHBoxLayout(row); auto* lay = new QHBoxLayout(row);
lay->setContentsMargins(0, 0, 0, 0); lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(12); lay->setSpacing(geopro::app::space::kLg);
auto* lbl = new QLabel(label, row);
lbl->setMinimumWidth(96); // 左侧标题列:标题在上,说明(可选)在下。
lay->addWidget(lbl); auto* labelCol = new QWidget(row);
lay->addWidget(control, 1); auto* lv = new QVBoxLayout(labelCol);
lv->setContentsMargins(0, 0, 0, 0);
lv->setSpacing(geopro::app::space::kXxs);
auto* lbl = new QLabel(label, labelCol);
geopro::app::applyTokenizedStyleSheet(
lbl, QStringLiteral("color:{{text/primary}}; font-size:%1px;")
.arg(geopro::app::scaledPx(geopro::app::type::kBody)));
lv->addWidget(lbl);
if (!caption.isEmpty()) {
auto* cap = new QLabel(caption, labelCol);
cap->setWordWrap(true);
geopro::app::applyTokenizedStyleSheet(
cap, QStringLiteral("color:{{text/tertiary}}; font-size:%1px;")
.arg(geopro::app::scaledPx(geopro::app::type::kCaption)));
lv->addWidget(cap);
}
labelCol->setMinimumWidth(160);
lay->addWidget(labelCol);
lay->addWidget(control, 1, Qt::AlignTop);
return row; return row;
} }
// 区段标题。 // 分组标题§7.10text/heading 字号 + text/secondary 色,下方 1px divider。
QLabel* sectionTitle(const QString& text, QWidget* parent) { QWidget* sectionTitle(const QString& text, QWidget* parent) {
auto* t = new QLabel(text, parent); auto* box = new QWidget(parent);
t->setStyleSheet(QStringLiteral("font-size:%1px; font-weight:600;") auto* v = new QVBoxLayout(box);
.arg(geopro::app::scaledPx(geopro::app::type::kHeading))); v->setContentsMargins(0, 0, 0, 0);
return t; v->setSpacing(geopro::app::space::kSm);
auto* t = new QLabel(text, box);
geopro::app::applyTokenizedStyleSheet(
t, QStringLiteral("color:{{text/secondary}}; font-size:%1px; font-weight:%2;")
.arg(geopro::app::scaledPx(geopro::app::type::kHeading))
.arg(geopro::app::type::kWeightSemibold));
v->addWidget(t);
auto* divider = new QFrame(box);
divider->setFrameShape(QFrame::HLine);
divider->setFixedHeight(1);
geopro::app::applyTokenizedStyleSheet(
divider, QStringLiteral("background:{{divider}}; border:none;"));
v->addWidget(divider);
return box;
} }
QWidget* buildAppearancePage() { QWidget* buildAppearancePage() {
@ -56,7 +94,8 @@ QWidget* buildAppearancePage() {
QObject::connect(themeCombo, &QComboBox::activated, page, [themeCombo](int) { QObject::connect(themeCombo, &QComboBox::activated, page, [themeCombo](int) {
geopro::app::setThemeModePreference(themeCombo->currentData().toString()); geopro::app::setThemeModePreference(themeCombo->currentData().toString());
}); });
v->addWidget(makeRow(QStringLiteral("主题"), themeCombo)); v->addWidget(makeRow(QStringLiteral("主题"), themeCombo,
QStringLiteral("跟随系统 / 浅色 / 深色,切换即时生效")));
// 界面字号:小/标准/大/特大(重启生效)。 // 界面字号:小/标准/大/特大(重启生效)。
auto* fontCombo = new QComboBox(page); auto* fontCombo = new QComboBox(page);
@ -66,12 +105,13 @@ QWidget* buildAppearancePage() {
fontCombo->addItem(QStringLiteral("特大"), 130); fontCombo->addItem(QStringLiteral("特大"), 130);
const int curScale = geopro::app::fontScalePreference(); const int curScale = geopro::app::fontScalePreference();
fontCombo->setCurrentIndex(fontCombo->findData(curScale) >= 0 ? fontCombo->findData(curScale) : 1); fontCombo->setCurrentIndex(fontCombo->findData(curScale) >= 0 ? fontCombo->findData(curScale) : 1);
v->addWidget(makeRow(QStringLiteral("界面字号"), fontCombo)); v->addWidget(makeRow(QStringLiteral("界面字号"), fontCombo,
QStringLiteral("小 / 标准 / 大 / 特大,重启后生效")));
// 字号改动:持久化 + 提示重启(提供立即重启)。 // 字号改动:持久化 + 提示重启(提供立即重启)。
auto* restartRow = new QWidget(page); auto* restartRow = new QWidget(page);
auto* rlay = new QHBoxLayout(restartRow); auto* rlay = new QHBoxLayout(restartRow);
rlay->setContentsMargins(96 + 12, 0, 0, 0); // 与控件列对齐 rlay->setContentsMargins(160 + geopro::app::space::kLg, 0, 0, 0); // 与控件列对齐
rlay->setSpacing(10); rlay->setSpacing(10);
auto* hint = new QLabel(QStringLiteral("界面字号将在重启后生效"), restartRow); auto* hint = new QLabel(QStringLiteral("界面字号将在重启后生效"), restartRow);
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
@ -135,12 +175,36 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) {
root->setContentsMargins(0, 0, 0, 0); root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0); root->setSpacing(0);
// 左:分类列表。 // 左分类导航§7.10)。分类项高 32px图标 + 名称;选中 = bg/selected 底 + 左 2px 竖条。
// 左竖条用「选中项 2px 左边框 + accent/primary」实现非选中项留同宽透明左边框防文字跳动。
auto* sidebar = new QListWidget(this); auto* sidebar = new QListWidget(this);
sidebar->setObjectName(QStringLiteral("settingsSidebar")); sidebar->setObjectName(QStringLiteral("settingsSidebar"));
sidebar->setFixedWidth(150); sidebar->setFixedWidth(160);
sidebar->addItem(QStringLiteral("外观")); sidebar->setIconSize(QSize(geopro::app::scaledPx(16), geopro::app::scaledPx(16)));
sidebar->addItem(QStringLiteral("关于")); geopro::app::applyTokenizedStyleSheet(
sidebar,
QStringLiteral(
"QListWidget#settingsSidebar{background:{{bg/panel}}; border:none;"
" border-right:1px solid {{divider}}; outline:none;}"
"QListWidget#settingsSidebar::item{min-height:%1px; padding-left:%2px;"
" border-left:2px solid transparent; color:{{text/primary}}; font-size:%3px;}"
"QListWidget#settingsSidebar::item:hover{background:{{bg/hover}};}"
"QListWidget#settingsSidebar::item:selected{background:{{bg/selected}};"
" border-left:2px solid {{accent/primary}}; color:{{text/primary}};}")
.arg(geopro::app::scaledPx(32))
.arg(geopro::app::space::kLg)
.arg(geopro::app::scaledPx(geopro::app::type::kBody)));
auto* appearanceItem = new QListWidgetItem(
geopro::app::makeGlyph(geopro::app::Glyph::Gear, geopro::app::tokenColor("text/secondary"),
geopro::app::scaledPx(16)),
QStringLiteral("外观"), sidebar);
auto* aboutItem = new QListWidgetItem(
geopro::app::makeGlyph(geopro::app::Glyph::Property,
geopro::app::tokenColor("text/secondary"), geopro::app::scaledPx(16)),
QStringLiteral("关于"), sidebar);
Q_UNUSED(appearanceItem);
Q_UNUSED(aboutItem);
root->addWidget(sidebar); root->addWidget(sidebar);
// 右:分页。 // 右:分页。

View File

@ -216,7 +216,7 @@ QPushButton {
background: {{bg/panel}}; background: {{bg/panel}};
color: {{text/primary}}; color: {{text/primary}};
border: 1px solid {{border/strong}}; border: 1px solid {{border/strong}};
border-radius: 6px; border-radius: 4px; /* radius/sm */
padding: 6px 14px; padding: 6px 14px;
} }
QPushButton:hover { QPushButton:hover {
@ -239,17 +239,24 @@ QPushButton:disabled {
color: {{text/disabled}}; color: {{text/disabled}};
border-color: {{border/default}}; border-color: {{border/default}};
} }
/* 输入框(规范 §7.1):默认 border/default、hover border/strong、focus border/focus。
~28px = 13px + padding 6px + 1px×2 min-height 16px
Qt QSS box-shadow focus border/focus */
QLineEdit { QLineEdit {
background: {{bg/panel}}; background: {{bg/panel}};
color: {{text/primary}}; color: {{text/primary}};
border: 1px solid {{border/strong}}; border: 1px solid {{border/default}};
border-radius: 6px; border-radius: 4px; /* radius/sm */
padding: 6px 8px; padding: 6px 8px;
min-height: 16px;
selection-background-color: {{accent/primary}}; selection-background-color: {{accent/primary}};
selection-color: {{text/on-primary}}; selection-color: {{text/on-primary}};
} }
QLineEdit:hover {
border-color: {{border/strong}};
}
QLineEdit:focus { QLineEdit:focus {
border: 1px solid {{accent/primary}}; border: 1px solid {{border/focus}};
} }
QLineEdit:disabled { QLineEdit:disabled {
background: {{bg/app}}; background: {{bg/app}};
@ -330,7 +337,7 @@ QMenuBar {
QMenuBar::item { QMenuBar::item {
background: transparent; background: transparent;
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: 4px; /* radius/sm */
} }
QMenuBar::item:selected { QMenuBar::item:selected {
background: {{bg/hover}}; background: {{bg/hover}};
@ -343,11 +350,12 @@ QMenu {
background: {{bg/panel}}; background: {{bg/panel}};
color: {{text/primary}}; color: {{text/primary}};
border: 1px solid {{border/default}}; border: 1px solid {{border/default}};
border-radius: 6px; /* radius/md浮层容器 */
padding: 4px; padding: 4px;
} }
QMenu::item { QMenu::item {
padding: 6px 24px 6px 14px; padding: 6px 24px 6px 14px;
border-radius: 6px; border-radius: 4px; /* radius/sm */
} }
QMenu::item:selected { QMenu::item:selected {
background: {{bg/hover}}; background: {{bg/hover}};
@ -360,19 +368,21 @@ QMenu::separator {
} }
/* ── 下拉框(按需出现时也与主题一致)──────────────────────── */ /* ── 下拉框(按需出现时也与主题一致)──────────────────────── */
/* 下拉框(规范 §7.2):外观对齐输入框 —— 默认 border/default、hover border/strong、
focus border/focusradius/sm radius/md hover bg/hover */
QComboBox { QComboBox {
background: {{bg/panel}}; background: {{bg/panel}};
color: {{text/primary}}; color: {{text/primary}};
border: 1px solid {{border/strong}}; border: 1px solid {{border/default}};
border-radius: 6px; border-radius: 4px; /* radius/sm */
padding: 6px 10px; padding: 6px 10px;
min-height: 18px; min-height: 16px;
} }
QComboBox:hover { QComboBox:hover {
border-color: {{accent/primary}}; border-color: {{border/strong}};
} }
QComboBox:focus { QComboBox:focus {
border-color: {{accent/primary}}; border-color: {{border/focus}};
} }
QComboBox::drop-down { QComboBox::drop-down {
border: none; border: none;
@ -381,6 +391,7 @@ QComboBox::drop-down {
QComboBox QAbstractItemView { QComboBox QAbstractItemView {
background: {{bg/panel}}; background: {{bg/panel}};
border: 1px solid {{border/default}}; border: 1px solid {{border/default}};
border-radius: 6px; /* radius/md浮层容器 */
outline: none; outline: none;
padding: 2px; padding: 2px;
} }
@ -424,6 +435,36 @@ QProgressBar::chunk {
border-radius: 6px; border-radius: 6px;
} }
/* ── 滑块(规范 §6.13如简化容差4px 轨道 + 已填充段强调色 + 14px 白圆手柄 ──
handle margin -5px 使 14px 4px app */
QSlider::groove:horizontal {
height: 4px;
background: {{border/default}};
border-radius: 2px;
}
QSlider::sub-page:horizontal {
background: {{accent/primary}};
border-radius: 2px;
}
QSlider::add-page:horizontal {
background: {{border/default}};
border-radius: 2px;
}
QSlider::handle:horizontal {
width: 14px;
height: 14px;
margin: -5px 0;
border-radius: 7px;
background: {{bg/panel}};
border: 1px solid {{border/strong}};
}
QSlider::handle:horizontal:hover {
border-color: {{accent/primary}};
}
/* 对话框容器圆角/投影(规范 §7.5 radius/lg + shadow/dialogQt 顶层原生窗口忽略
QSS border-radius QSS /QGraphicsEffect */
/* ── ADS 停靠:标题栏做成「固定面板表头」(对齐原型)────────────── /* ── ADS 停靠:标题栏做成「固定面板表头」(对齐原型)──────────────
(//) + (//) +
线 + */ 线 + */

View File

@ -3,11 +3,9 @@
// 全局视觉主题浅色专业方向Fusion 风格 + 浅色 QPalette + 结构化 QSS。 // 全局视觉主题浅色专业方向Fusion 风格 + 浅色 QPalette + 结构化 QSS。
// 仅外观——不改任何信号槽 / 渲染 / 数据逻辑。在 QApplication 构造后、弹登录窗前调用一次。 // 仅外观——不改任何信号槽 / 渲染 / 数据逻辑。在 QApplication 构造后、弹登录窗前调用一次。
// //
// 设计令牌(与登录窗、视图详情浮层共享,保证全项目一脉相承): // 设计令牌唯一事实来源 = Theme.cpp 的 kTokens 表(规范 §1.5)。组件只引语义 token
// 外壳底 #F4F6FA 面板白 #FFFFFF 抬升/表头 #EDF1F7 // token()/{{token}}),禁止在此散落硬编码 hex。速查bg/app #F7F8FA · accent/primary
// 强调 #2D6CB5 悬停 #2862A6 按下 #234F87 选中行 #DCE9F8 // #3B73EC · text/primary #272C35 · border/default #E3E6EB · status/danger #E5484D。
// 文字 #1F2A3D 次要文字 #5A6B85 边框 #D5DBE5 强边框 #C2CCDA
// 危险 #C0392B
#include <QColor> #include <QColor>
#include <QObject> #include <QObject>
@ -56,6 +54,10 @@ inline constexpr int kWeightMedium = 500;
inline constexpr int kWeightSemibold = 600; inline constexpr int kWeightSemibold = 600;
inline constexpr int kWeightBold = 700; inline constexpr int kWeightBold = 700;
// 等宽字族(规范 §2.1):坐标/数值/编号/深度刻度用,保证逐列对齐。
// 仅在此暴露,组件阶段(数值/坐标/ID 标签)按需引用,不在全局 QSS 强加。
inline constexpr const char* kMonoFamily = "Cascadia Code, JetBrains Mono, Consolas, monospace";
} // namespace type } // namespace type
// ── 间距令牌(全项目唯一间距阶)────────────────────────────────── // ── 间距令牌(全项目唯一间距阶)──────────────────────────────────
@ -75,33 +77,28 @@ inline constexpr int kXl = 16; // 区块内边距
inline constexpr int kXxl = 24; // 区块间距、表单纵向边距 inline constexpr int kXxl = 24; // 区块间距、表单纵向边距
inline constexpr int kXxxl = 32; // 页面级留白(登录窗左右边距) inline constexpr int kXxxl = 32; // 页面级留白(登录窗左右边距)
// 可编辑表单标签列宽(规范 §7.0.2:默认 96120px同表单内等宽对齐右标签
// 唯一事实来源——DynamicFormEditor / ObjectAttrPanel / ObjectFormDialog 的左标签列统一引此值。
// 注纯只读键值表§6.4)另用 72px见 DynamicFormView::kKeyColWidth不复用此档。
inline constexpr int kFormLabelCol = 100;
// 可编辑字段最大宽(规范 §7.0.2:宽对话框中「不要拉满」,单字段最大约 360px
// 窄属性面板里该上限大于面板宽,故字段仍填满——符合规范。多行/长文本不受此限。
inline constexpr int kFormFieldMax = 360;
} // namespace space } // namespace space
// ── 圆角令牌(统一原先 4/5/6/7/8/9 共 6 档为 3 档)──────────────── // ── 圆角令牌(规范 §3.2)────────────────────────────────────────
// 圆形元素(头像等)用 直径/2 单独写字面量,不入档。 // 圆形元素(头像等)用 直径/2 单独写字面量,不入档。
namespace radius { namespace radius {
inline constexpr int kSm = 6; // 按钮·输入·菜单项·滚动条·进度条 inline constexpr int kSm = 4; // 按钮·输入框·标签
inline constexpr int kMd = 8; // 卡片·面板·对话框·菜单·分组框 inline constexpr int kMd = 6; // 卡片·列表项·浮层·菜单
inline constexpr int kPill = 9; // 数量徽标胶囊 inline constexpr int kLg = 8; // 对话框·画布浮窗·分组框
inline constexpr int kPill = 999; // 胶囊标签·开关·计数徽标
} // namespace radius } // namespace radius
// ── 语义色令牌(状态/反馈,产品语境:只在承载含义处用,不作装饰)──────────
// 文字值均针对白底面板(#FFFFFF)选深色,对比度 ≥4.5:1正文级与冷调中性
// 调色板调和。danger 沿用既有红,避免引入第二种红。
namespace semantic {
inline constexpr const char* kInfo = "#2D6CB5"; // 信息·进行中(= 品牌蓝)
inline constexpr const char* kSuccess = "#15803D"; // 成功·已完成(深绿)
inline constexpr const char* kWarning = "#B45309"; // 警告·需注意(深琥珀)
inline constexpr const char* kDanger = "#C0392B"; // 危险·错误(沿用既有红)
// 浅色填充(徽标/标签底色,配同族深色文字使用)。
inline constexpr const char* kWarningFill = "#FBEAD2"; // 警告底纹(配 kWarning 文字)
} // namespace semantic
// 应用专业主题Fusion + 调色板 + 全局样式表。dark=true 走暗色P2 主题桥用)。 // 应用专业主题Fusion + 调色板 + 全局样式表。dark=true 走暗色P2 主题桥用)。
// 暗色复用同一 QSS 结构,颜色全由 kTokens 双值fillTokens/tokenHex驱动幂等可随主题切换重复调用。 // 暗色复用同一 QSS 结构,颜色全由 kTokens 双值fillTokens/tokenHex驱动幂等可随主题切换重复调用。
void applyThemeMode(QApplication& app, bool dark); void applyThemeMode(QApplication& app, bool dark);

107
src/app/ToastOverlay.cpp Normal file
View File

@ -0,0 +1,107 @@
#include "ToastOverlay.hpp"
#include <QEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QTimer>
#include "Theme.hpp"
namespace geopro::app {
namespace {
// ── 自动消失时长(规范 §7.734s──
constexpr int kDismissMs = 3500;
// 距窗口内容区底部的留白(引用间距令牌 xxl
constexpr int kBottomMargin = space::kXxl;
// 卡片最大宽度,避免长文案铺满整窗。
constexpr int kMaxWidth = 480;
// 左侧状态色竖条宽度(规范 §7.7状态色左竖条3px
constexpr int kBarWidth = 3;
} // namespace
ToastOverlay::ToastOverlay(QWidget* parent) : QWidget(parent) {
setObjectName(QStringLiteral("toastCard"));
setAttribute(Qt::WA_StyledBackground, true); // 令 QSS 背景在 QWidget 上生效
setCursor(Qt::PointingHandCursor);
hide();
// 卡片样式bg/panel 底 + 1px border/default 边框 + radius/md 圆角;左侧 3px status/info 状态竖条
// 以「左 border-left」近似阴影无法用 QSS 绘制,故以边框 + 半透明底近似浮层高度。
applyTokenizedStyleSheet(
this, QStringLiteral(
"#toastCard { background:{{bg/panel}}; border:1px solid {{border/default}};"
" border-left:%1px solid {{status/info}}; border-radius:%2px; }"
"#toastLabel { color:{{text/primary}}; font-size:%3px; background:transparent;"
" border:none; }")
.arg(kBarWidth)
.arg(radius::kMd)
.arg(scaledPx(type::kBody)));
auto* lay = new QHBoxLayout(this);
// 左内边距补上状态竖条宽度,文案与竖条留出间隙。
lay->setContentsMargins(space::kLg + kBarWidth, space::kMd, space::kLg, space::kMd);
lay->setSpacing(space::kMd);
label_ = new QLabel(this);
label_->setObjectName(QStringLiteral("toastLabel"));
label_->setWordWrap(true);
label_->setMaximumWidth(kMaxWidth);
lay->addWidget(label_);
// 单次计时器:到时隐藏(复用实例不销毁,规避悬垂指针)。
timer_ = new QTimer(this);
timer_->setSingleShot(true);
QObject::connect(timer_, &QTimer::timeout, this, [this] { hide(); });
// 监听父窗口尺寸/移动,跟随重定位。
if (parent) parent->installEventFilter(this);
}
void ToastOverlay::showMessage(const QString& msg) {
if (!label_) return;
label_->setText(msg);
adjustSize();
reposition();
raise();
show();
timer_->start(kDismissMs); // 重置自动消失计时
}
void ToastOverlay::reposition() {
auto* p = parentWidget();
if (!p) return;
const int x = (p->width() - width()) / 2;
const int y = p->height() - height() - kBottomMargin;
move(qMax(0, x), qMax(0, y));
}
bool ToastOverlay::eventFilter(QObject* obj, QEvent* event) {
if (obj == parentWidget() &&
(event->type() == QEvent::Resize || event->type() == QEvent::Move)) {
if (isVisible()) reposition();
}
return QWidget::eventFilter(obj, event);
}
void ToastOverlay::mousePressEvent(QMouseEvent* event) {
hide(); // 点击立即关闭
timer_->stop();
QWidget::mousePressEvent(event);
}
void showToast(QWidget* anchorWindow, const QString& msg) {
if (!anchorWindow) return;
QWidget* win = anchorWindow->window(); // 取顶层窗口作为锚
if (!win) return;
// 同一窗口复用一个 overlay首次创建并以窗口为父后续按 objectName 找回直接替换文案。
auto* overlay = win->findChild<ToastOverlay*>(QStringLiteral("toastCard"));
if (!overlay) overlay = new ToastOverlay(win);
overlay->showMessage(msg);
}
} // namespace geopro::app

42
src/app/ToastOverlay.hpp Normal file
View File

@ -0,0 +1,42 @@
#pragma once
// 浮动轻提示(规范 §7.7 Toast底部居中浮出的小卡片bg/panel 底 + 1px border/default 边框
// + 左侧 3px 状态色竖条(默认 status/info+ 文案;~3500ms 自动消失,点击立即关闭。
// 纯外观组件,不触碰任何业务/渲染/数据逻辑。
//
// 实现取「单例可复用」方案:每个顶层窗口共用一个 ToastOverlay 子控件,新消息直接替换当前文案
// 并重置计时器——避免多实例叠放带来的悬垂指针风险。作为锚窗口的子控件,随窗口移动/缩放自动跟随。
// Qt QSS 画不出阴影,故以「边框 + 半透明底」近似浮层高度,不强行模拟 box-shadow。
#include <QWidget>
class QLabel;
class QTimer;
namespace geopro::app {
// 浮动 Toast 卡片:作为锚窗口的子控件存在,定位在窗口内容区底部居中。
class ToastOverlay : public QWidget {
Q_OBJECT
public:
explicit ToastOverlay(QWidget* parent);
// 显示一条提示(替换当前内容并重置自动消失计时器)。
void showMessage(const QString& msg);
protected:
bool eventFilter(QObject* obj, QEvent* event) override; // 跟随锚窗口尺寸/移动变化重定位
void mousePressEvent(QMouseEvent* event) override; // 点击立即关闭
private:
void reposition(); // 依据父窗口内容区,置于底部居中
QLabel* label_ = nullptr;
QTimer* timer_ = nullptr;
};
// 便捷自由函数:在 anchorWindow 上显示一条 Toast。多次调用复用同一个 overlay 实例。
// anchorWindow 取传入控件的顶层窗口,保证 toast 浮在主窗口内容区上。
void showToast(QWidget* anchorWindow, const QString& msg);
} // namespace geopro::app

View File

@ -10,10 +10,10 @@
#include <QIcon> #include <QIcon>
#include <QLabel> #include <QLabel>
#include <QMenu> #include <QMenu>
#include <QMenuBar>
#include <QPainter> #include <QPainter>
#include <QPixmap> #include <QPixmap>
#include <QSize> #include <QSize>
#include <QTimer>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QStringList> #include <QStringList>
#include <QToolButton> #include <QToolButton>
@ -27,9 +27,9 @@ namespace geopro::app {
namespace { namespace {
// ── 专业图标尺寸(统一放大;菜单栏字号亦同步加大)── // ── 工具条图标尺寸贴近常见桌面客户端16px 紧凑)──
constexpr int kToolIcon = 22; // 工具条右侧图标 constexpr int kToolIcon = 16; // 工具条右侧图标
constexpr int kWorkspaceIcon = 20; // 工作空间 / 项目图标 constexpr int kWorkspaceIcon = 16; // 工作空间 / 项目图标
// 竖直分隔细线。 // 竖直分隔细线。
QFrame* makeDivider(QWidget* parent) QFrame* makeDivider(QWidget* parent)
@ -38,7 +38,7 @@ QFrame* makeDivider(QWidget* parent)
line->setObjectName(QStringLiteral("topDivider")); line->setObjectName(QStringLiteral("topDivider"));
line->setFrameShape(QFrame::VLine); line->setFrameShape(QFrame::VLine);
line->setFixedWidth(1); line->setFixedWidth(1);
line->setFixedHeight(24); line->setFixedHeight(20);
return line; return line;
} }
@ -55,6 +55,46 @@ QWidget* makeIconButton(QWidget* parent, Glyph icon, const QString& tip)
return btn; return btn;
} }
// 通知红点(规范 §5在铃铛按钮右上角叠一个 8px 实心圆点作未读指示token 色 status/danger。
// 做法:以按钮为父建一个圆形 QLabel绝对定位到右上角透明穿透鼠标不影响按钮 hover/点击。
// 尺寸随 scaledPx 缩放;按钮 32×32 内右上偏内 2px。当前为常驻静态指示UI 合规通过即可)。
void attachNotificationDot(QWidget* bellBtn)
{
const int dot = scaledPx(8); // 8px 圆点(随字号缩放)
auto* badge = new QLabel(bellBtn);
badge->setObjectName(QStringLiteral("notifDot"));
badge->setAttribute(Qt::WA_TransparentForMouseEvents); // 不拦截按钮点击/hover
badge->setFixedSize(dot, dot);
applyTokenizedStyleSheet(
badge, QStringLiteral("#notifDot { background:{{status/danger}}; border-radius:%1px; }")
.arg(dot / 2));
// 右上角内收 2px 定位。延迟到布局/缩放生效后再按真实宽度定位(构造期 width 可能尚未确定),
// 避免换字号/重排时红点错位。
const int margin = scaledPx(2);
auto place = [badge, bellBtn, dot, margin] {
badge->move(bellBtn->width() - dot - margin, margin);
badge->raise();
badge->show();
};
place();
QTimer::singleShot(0, badge, place);
}
// 一级菜单工具按钮:纯文字 + 下拉箭头chevron menu-indicatorInstantPopup 挂菜单。
// 文字取菜单标题(视图/项目管理/…),样式见 #menuBtn QSS。
QToolButton* makeMenuButton(QWidget* parent, QMenu* menu)
{
auto* btn = new QToolButton(parent);
btn->setObjectName(QStringLiteral("menuBtn"));
btn->setText(menu->title());
btn->setMenu(menu);
btn->setPopupMode(QToolButton::InstantPopup);
btn->setToolButtonStyle(Qt::ToolButtonTextOnly);
btn->setCursor(Qt::PointingHandCursor);
btn->setAutoRaise(true);
return btn;
}
// 圆形头像图标:强调色填充 + 白色缩写。2x 绘制保证高 DPI 清晰。 // 圆形头像图标:强调色填充 + 白色缩写。2x 绘制保证高 DPI 清晰。
QPixmap renderAvatar(const QString& initials, int px, const QColor& bg, const QColor& fg) QPixmap renderAvatar(const QString& initials, int px, const QColor& bg, const QColor& fg)
{ {
@ -141,44 +181,37 @@ QMenu* buildDeviceMenu(QWidget* p)
} // namespace } // namespace
QWidget* buildMenuBar(QWidget* parent)
{
auto* mb = new QMenuBar(parent);
mb->setObjectName(QStringLiteral("appMenuBar"));
// ElaMenuBar 自绘 Fluent 外观并自动随 ElaTheme 明暗,不再写内联 QSS。
mb->addMenu(buildViewMenu(mb));
mb->addMenu(buildProjectMenu(mb));
mb->addMenu(buildToolsMenu(mb));
mb->addMenu(buildDeviceMenu(mb));
return mb;
}
TopBar::TopBar(QWidget* parent) : QWidget(parent) { TopBar::TopBar(QWidget* parent) : QWidget(parent) {
setObjectName(QStringLiteral("appToolBar")); setObjectName(QStringLiteral("appToolBar"));
setFixedHeight(56); setFixedHeight(40);
// 字号引用 Theme 排版令牌:工作空间切换器=title(15)、头像/用户名=body·label(13)、 // 字号引用 Theme 排版令牌:切换器/一级菜单按钮=body(13)、头像/用户名=body·label(13)、
// 角色名=caption(12)。原 11px 角色名上调到 12去掉只差 1px 的糊层级 // 角色名=caption(12)。工具条整体收紧到常见桌面客户端尺寸(行高 40 / 图标 16 / 字号 13
// 切换器(ElaToolButton)/图标(ElaIconButton) 自绘 Fluent不再写它们的 QSS。 // 切换器(ElaToolButton)/图标(ElaIconButton) 自绘 Fluent不再写它们的 QSS。
// 仅保留:工具条底/分隔线、头像(圆形自定义)、用户名/角色。头像白字用 {{text/on-primary}} 令牌。 // 仅保留:工具条底/分隔线、一级菜单按钮、头像(圆形自定义)、用户名/角色。头像白字用 {{text/on-primary}} 令牌。
// 切换器下拉箭头:用生成的高清 chevron PNG 作 menu-indicator替代旧的粗糙文字箭头),中性灰双主题可读。 // 切换器/菜单按钮下拉箭头:用生成的高清 chevron PNG 作 menu-indicator中性灰双主题可读
const QString chevron = geopro::app::writeChevronIcon(true, QColor("#7C8493")); const QString chevron = geopro::app::writeChevronIcon(true, QColor("#7C8493"));
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
this, QStringLiteral( this, QStringLiteral(
"#appToolBar { background:{{bg/header}}; border-bottom:1px solid {{divider}}; }" "#appToolBar { background:{{bg/header}}; border-bottom:1px solid {{divider}}; }"
"#topDivider { color:{{divider}}; }" "#topDivider { color:{{divider}}; }"
"QToolButton::menu-indicator { image:none; }" "QToolButton::menu-indicator { image:none; }"
"#wsSwitcher { color:{{text/primary}}; border:none; border-radius:8px; padding:8px 26px 8px 12px;" "#wsSwitcher { color:{{text/primary}}; border:none; border-radius:8px; padding:6px 24px 6px 10px;"
" font-size:%6px; font-weight:%4; }" " font-size:%6px; font-weight:%4; }"
"#wsSwitcher:hover { background:{{bg/hover}}; }" "#wsSwitcher:hover { background:{{bg/hover}}; }"
"#wsSwitcher::menu-indicator { image:url(%7); width:13px; height:13px;" "#wsSwitcher::menu-indicator { image:url(%7); width:12px; height:12px;"
" subcontrol-position: right center; subcontrol-origin: padding; right:8px; }" " subcontrol-position: right center; subcontrol-origin: padding; right:8px; }"
"QToolButton#iconBtn { border:none; border-radius:8px; padding:8px; }" "QToolButton#menuBtn { color:{{text/primary}}; border:none; border-radius:8px;"
" padding:6px 24px 6px 10px; font-size:%3px; font-weight:%4; }"
"QToolButton#menuBtn:hover { background:{{bg/hover}}; }"
"QToolButton#menuBtn::menu-indicator { image:url(%7); width:12px; height:12px;"
" subcontrol-position: right center; subcontrol-origin: padding; right:6px; }"
"QToolButton#iconBtn { border:none; border-radius:8px; padding:6px; }"
"QToolButton#iconBtn:hover { background:{{bg/hover}}; }" "QToolButton#iconBtn:hover { background:{{bg/hover}}; }"
"#userBtn { border:none; border-radius:8px; padding:4px 10px 4px 6px;" "#userBtn { border:none; border-radius:8px; padding:4px 10px 4px 6px;"
" color:{{text/primary}}; font-size:%3px; }" " color:{{text/primary}}; font-size:%3px; }"
"#userBtn:hover { background:{{bg/hover}}; }" "#userBtn:hover { background:{{bg/hover}}; }"
"#userBtn::menu-indicator { image:none; }" "#userBtn::menu-indicator { image:none; }"
"#avatar { background:{{accent/primary}}; color:{{text/on-primary}}; border-radius:17px; font-weight:%2;" "#avatar { background:{{accent/primary}}; color:{{text/on-primary}}; border-radius:15px; font-weight:%2;"
" font-size:%1px; }" " font-size:%1px; }"
"#userName { color:{{text/primary}}; font-size:%3px; font-weight:%4; }" "#userName { color:{{text/primary}}; font-size:%3px; font-weight:%4; }"
"#userRole { color:{{text/tertiary}}; font-size:%5px; }") "#userRole { color:{{text/tertiary}}; font-size:%5px; }")
@ -187,7 +220,7 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
.arg(scaledPx(type::kLabel)) .arg(scaledPx(type::kLabel))
.arg(type::kWeightSemibold) .arg(type::kWeightSemibold)
.arg(scaledPx(type::kCaption)) .arg(scaledPx(type::kCaption))
.arg(scaledPx(type::kTitle)) .arg(scaledPx(type::kBody))
.arg(chevron)); .arg(chevron));
auto* lay = new QHBoxLayout(this); auto* lay = new QHBoxLayout(this);
@ -220,10 +253,25 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
projBtn_->setMenu(new QMenu(projBtn_)); projBtn_->setMenu(new QMenu(projBtn_));
lay->addWidget(projBtn_); lay->addWidget(projBtn_);
lay->addSpacing(10);
lay->addWidget(makeDivider(this));
lay->addSpacing(10);
// 一级菜单 → 工具条按钮(视图/项目管理/业务工具/设备),纯文字 + 下拉箭头。
// 复用原菜单构造器;菜单作为 popup 挂到按钮(按钮文字取菜单标题)。
lay->addWidget(makeMenuButton(this, buildViewMenu(this)));
lay->addWidget(makeMenuButton(this, buildProjectMenu(this)));
lay->addWidget(makeMenuButton(this, buildToolsMenu(this)));
lay->addWidget(makeMenuButton(this, buildDeviceMenu(this)));
lay->addStretch(); lay->addStretch();
lay->addWidget(makeIconButton(this, Glyph::Help, QStringLiteral("帮助"))); lay->addWidget(makeIconButton(this, Glyph::Help, QStringLiteral("帮助")));
lay->addWidget(makeIconButton(this, Glyph::Bell, QStringLiteral("通知"))); // 通知铃铛:固定 32×32规范 §5 图标按钮尺寸),右上角叠常驻未读红点。
auto* bellBtn = makeIconButton(this, Glyph::Bell, QStringLiteral("通知"));
bellBtn->setFixedSize(scaledPx(32), scaledPx(32)); // §5 图标按钮 32×32随字号缩放
attachNotificationDot(bellBtn);
lay->addWidget(bellBtn);
auto* gearBtn = makeIconButton(this, Glyph::Gear, QStringLiteral("设置")); auto* gearBtn = makeIconButton(this, Glyph::Gear, QStringLiteral("设置"));
if (auto* gb = qobject_cast<QAbstractButton*>(gearBtn)) if (auto* gb = qobject_cast<QAbstractButton*>(gearBtn))
QObject::connect(gb, &QAbstractButton::clicked, this, [this] { emit settingsRequested(); }); QObject::connect(gb, &QAbstractButton::clicked, this, [this] { emit settingsRequested(); });
@ -240,13 +288,13 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
userRow_->setCursor(Qt::PointingHandCursor); userRow_->setCursor(Qt::PointingHandCursor);
userRow_->installEventFilter(this); userRow_->installEventFilter(this);
auto* uLay = new QHBoxLayout(userRow_); auto* uLay = new QHBoxLayout(userRow_);
uLay->setContentsMargins(8, 3, 8, 3); uLay->setContentsMargins(8, 2, 8, 2);
uLay->setSpacing(10); uLay->setSpacing(8);
auto* avatar = new QLabel(userRow_); auto* avatar = new QLabel(userRow_);
avatar->setPixmap( avatar->setPixmap(
renderAvatar(QStringLiteral("ZL"), 34, geopro::app::tokenColor("accent/primary"), Qt::white)); renderAvatar(QStringLiteral("ZL"), 30, geopro::app::tokenColor("accent/primary"), Qt::white));
avatar->setFixedSize(34, 34); avatar->setFixedSize(30, 30);
avatar->setAttribute(Qt::WA_TransparentForMouseEvents); avatar->setAttribute(Qt::WA_TransparentForMouseEvents);
uLay->addWidget(avatar, 0, Qt::AlignVCenter); uLay->addWidget(avatar, 0, Qt::AlignVCenter);

View File

@ -9,10 +9,7 @@ class QMenu;
namespace geopro::app { namespace geopro::app {
// 顶部菜单栏(静态,本轮不接真实页面)。 // 顶部工具条:数据驱动的工作空间/项目切换器 + 一级菜单按钮 + 右侧图标 + 用户区。
QWidget* buildMenuBar(QWidget* parent);
// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。
class TopBar : public QWidget { class TopBar : public QWidget {
Q_OBJECT Q_OBJECT
public: public:

View File

@ -171,6 +171,17 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
} }
} }
void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) {
// 2D 足迹:经共享 frame 投影到世界 XY、Z=worldZ。按 dsId 跟踪(与帘面同 dsProps_ → removeDataset 复用)。
// worldZ 已是最终世界高程(含摆放语义),不再施加 VE足迹是水平线非随深度的竖直图元
auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_);
if (actor) {
scene_.addActor(actor);
dsProps_[dsId].push_back(actor);
}
}
void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) { void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) {
auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_, auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_,
verticalExaggeration_); verticalExaggeration_);

View File

@ -33,11 +33,14 @@ public:
void clear() override; void clear() override;
void setVerticalExaggeration(double ve) override; void setVerticalExaggeration(double ve) override;
double zRefElev() const override { return zRefElev_; }
void addSurveyLine(const geopro::core::Grid& grid) override; void addSurveyLine(const geopro::core::Grid& grid) override;
void addCurtain(const std::string& dsId, const geopro::core::Grid& grid, void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
const geopro::core::ColorScale& cs) override; const geopro::core::ColorScale& cs) override;
void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) override; const geopro::core::ColorScale& cs) override;
void addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) override;
void addTerrain(const geopro::data::TerrainPaths& paths) override; void addTerrain(const geopro::data::TerrainPaths& paths) override;
void removeDataset(const std::string& dsId) override; void removeDataset(const std::string& dsId) override;
void addAnomaly(const geopro::core::Anomaly& a) override; void addAnomaly(const geopro::core::Anomaly& a) override;

View File

@ -101,6 +101,7 @@
#include "SettingsDialog.hpp" #include "SettingsDialog.hpp"
#include "SlicePropertiesDialog.hpp" #include "SlicePropertiesDialog.hpp"
#include "SliceExport.hpp" #include "SliceExport.hpp"
#include "ToastOverlay.hpp"
#include "TopBar.hpp" #include "TopBar.hpp"
#include "VolumeParamsDialog.hpp" #include "VolumeParamsDialog.hpp"
#include "VolumePropertiesDialog.hpp" #include "VolumePropertiesDialog.hpp"
@ -121,6 +122,7 @@
#include "panels/chart/GridStrategy.hpp" #include "panels/chart/GridStrategy.hpp"
#include "api/ApiProjectRepository.hpp" #include "api/ApiProjectRepository.hpp"
#include "api/ApiDatasetRepository.hpp" #include "api/ApiDatasetRepository.hpp"
#include "api/ApiColorTemplateRepository.hpp"
#include "api/Api3dRepository.hpp" #include "api/Api3dRepository.hpp"
#include "panels/ObjectTreePanel.hpp" #include "panels/ObjectTreePanel.hpp"
#include "login/LoginWindow.hpp" #include "login/LoginWindow.hpp"
@ -167,6 +169,8 @@
#include <vtkLookupTable.h> #include <vtkLookupTable.h>
#include <vtkProperty.h> #include <vtkProperty.h>
#include <vtkImageData.h> #include <vtkImageData.h>
#include <vtkDataArray.h>
#include <vtkPointData.h>
#include <vtkRenderWindowInteractor.h> #include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include <vtkSmartPointer.h> #include <vtkSmartPointer.h>
@ -229,6 +233,7 @@ constexpr const char* kWgs84 = "EPSG:4326";
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
geopro::data::IAsyncProjectRepository& projectRepo, geopro::data::IAsyncProjectRepository& projectRepo,
geopro::data::IAsyncDatasetRepository& datasetRepo, geopro::data::IAsyncDatasetRepository& datasetRepo,
geopro::data::IColorTemplateRepository& colorTplRepo,
geopro::controller::WorkbenchNavController& nav, geopro::controller::WorkbenchNavController& nav,
geopro::controller::DatasetDetailController& detailCtrl) geopro::controller::DatasetDetailController& detailCtrl)
{ {
@ -773,7 +778,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。 // 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。
// 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。 // 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。
QObject::connect(ca, &geopro::app::Column3DAnalysis::colorScaleRequested, &window, QObject::connect(ca, &geopro::app::Column3DAnalysis::colorScaleRequested, &window,
[&window, sceneCtrl, sceneView](const QString& qid) { [&window, &colorTplRepo, &nav, sceneCtrl, sceneView](const QString& qid) {
const std::string dsId = qid.toStdString(); const std::string dsId = qid.toStdString();
if (sceneView->currentVolumeDsId() != dsId || !sceneView->hasVolume()) { if (sceneView->currentVolumeDsId() != dsId || !sceneView->hasVolume()) {
QMessageBox::information( QMessageBox::information(
@ -781,9 +786,29 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QStringLiteral("请先勾选该三维体使其渲染后再编辑色阶。")); QStringLiteral("请先勾选该三维体使其渲染后再编辑色阶。"));
return; return;
} }
// 等积分层需原始标量:从当前体素 image 抽取(无则等积退化线性)。
// 大体素按步长抽样(等积分位无需全量点),避免主线程长循环卡 UI。
std::vector<double> samples;
if (vtkImageData* img = sceneView->currentVolumeImage()) {
if (vtkDataArray* sc = img->GetPointData()->GetScalars()) {
const vtkIdType n = sc->GetNumberOfTuples();
if (n > 0) {
constexpr vtkIdType kMaxSamples = 200000;
const vtkIdType stride =
(n > kMaxSamples) ? (n / kMaxSamples) : 1;
samples.reserve(
static_cast<std::size_t>(n / stride + 1));
for (vtkIdType i = 0; i < n; i += stride)
samples.push_back(sc->GetComponent(i, 0));
}
}
}
// 3D 无等值线,线形/标注配置忽略(用默认);仅取色阶应用。
// 「另存为/打开」与「新建色阶/配色方案」走色阶模板仓储projectId 取当前项目。
geopro::app::ColorScaleConfigDialog dlg( geopro::app::ColorScaleConfigDialog dlg(
sceneView->currentColorScale(), sceneView->currentVmin(), sceneView->currentColorScale(), sceneView->currentVmin(),
sceneView->currentVmax(), &window); sceneView->currentVmax(), std::move(samples), {}, &colorTplRepo,
nav.currentProjectId(), &window);
if (dlg.exec() == QDialog::Accepted) if (dlg.exec() == QDialog::Accepted)
sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale()); sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale());
}); });
@ -829,6 +854,26 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
: geopro::app::TileBasemap::Hidden; : geopro::app::TileBasemap::Hidden;
basemap->show(*basemapKind); basemap->show(*basemapKind);
}); });
// ── 二维数据集栏:勾选足迹(测线/轨迹) → 平铺进 View3D 地图2D视图下拉控摆放高度 ──
// 足迹经控制器 loadMapLine(Api3dRepository 走 dd/ert/trajectory/line 端点) → addMapLine 至
// 当前摆放 Z与帘面/底图共享 GeoLocalFrame 配准。与 3D 勾选集独立、按 dsId 增量。
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::checkedDatasetsChanged,
sceneCtrl, &geopro::controller::VtkSceneController::setChecked2DDatasets);
// 2D视图下拉(0关闭/1 Z=0/2顶部/3底部/4自定义) + 自定义 Z → 控制器重摆已勾选足迹。
auto custom2dZ = std::make_shared<double>(0.0);
auto view2dMode = std::make_shared<int>(0);
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::view2DModeChanged, sceneCtrl,
[sceneCtrl, custom2dZ, view2dMode](int mode) {
*view2dMode = mode;
sceneCtrl->set2DPlacement(mode, *custom2dZ);
});
// 自定义 Z 变化:记录;若当前正处自定义模式则即时重摆(控制器内 changed 判定避免无谓重画)。
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::customZChanged, sceneCtrl,
[sceneCtrl, custom2dZ, view2dMode](double z) {
*custom2dZ = z;
if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z);
});
// 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置 // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置
// (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。
sceneView->onFrameReanchored = [basemap, basemapKind]() { sceneView->onFrameReanchored = [basemap, basemapKind]() {
@ -898,6 +943,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// ── 下方「数据详情」dock平面图表多 Tab 面板QGraphicsViewVTK 仅算几何)── // ── 下方「数据详情」dock平面图表多 Tab 面板QGraphicsViewVTK 仅算几何)──
// 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。
auto* detailPanel = new geopro::app::DatasetDetailPanel(); auto* detailPanel = new geopro::app::DatasetDetailPanel();
// 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。
detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); });
auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情"));
// ForceNoScrollArea禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。 // ForceNoScrollArea禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。
// 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充; // 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充;
@ -1099,19 +1146,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, sceneCtrl, QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, sceneCtrl,
[sceneCtrl]() { sceneCtrl->rebuild(); }); [sceneCtrl]() { sceneCtrl->rebuild(); });
// 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备), // 顶部应用区:单行工具条(工作空间/项目切换 + 一级菜单按钮 视图/项目管理/业务工具/设备
// 下=工具条(工作空间切换 + 项目 + 帮助/通知/设置 + 用户)。纵向堆叠后挂到主窗口顶部。 // + 帮助/通知/设置 + 用户)。菜单栏已去除,一级菜单改为工具条上的下拉按钮。
geopro::app::TopBar* topBar = nullptr; geopro::app::TopBar* topBar = new geopro::app::TopBar(&window);
{ window.setMenuWidget(topBar);
auto* topChrome = new QWidget(&window);
auto* topLayout = new QVBoxLayout(topChrome);
topLayout->setContentsMargins(0, 0, 0, 0);
topLayout->setSpacing(0);
topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
topBar = new geopro::app::TopBar(topChrome);
topLayout->addWidget(topBar);
window.setMenuWidget(topChrome);
}
// ── 控制器 ↔ UI 信号接线(导航壳)────────────────────────────────────── // ── 控制器 ↔ UI 信号接线(导航壳)──────────────────────────────────────
// "加载更多"行:列表末尾若已加载数 < 总数,放一行可点击的"加载更多(已/共)"。 // "加载更多"行:列表末尾若已加载数 < 总数,放一行可点击的"加载更多(已/共)"。
@ -1202,8 +1240,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// ── 交互操作接线(对象/数据集视图:右键菜单 + 快速筛选 + 增删2D/3D 相关占位)──────── // ── 交互操作接线(对象/数据集视图:右键菜单 + 快速筛选 + 增删2D/3D 相关占位)────────
auto* anomalyTabGroup = anomalyPanel.tabGroup; // 捕指针anomalyPanel 为局部,勿按引用捕获) auto* anomalyTabGroup = anomalyPanel.tabGroup; // 捕指针anomalyPanel 为局部,勿按引用捕获)
// 状态栏轻提示toast 替代window 生命周期覆盖整个会话,按引用捕获安全)。 // 浮动轻提示(规范 §7.7 Toast底部居中浮出小卡片window 生命周期覆盖整个会话,按引用捕获安全)。
auto toast = [&window](const QString& msg) { window.statusBar()->showMessage(msg, 4000); }; auto toast = [&window](const QString& msg) { geopro::app::showToast(&window, msg); };
// 表头操作按钮定位器:按 glyphId 在已包装面板表头里找具体 QToolButton。 // 表头操作按钮定位器:按 glyphId 在已包装面板表头里找具体 QToolButton。
auto findHeaderAction = [](QWidget* box, geopro::app::Glyph g) -> QToolButton* { auto findHeaderAction = [](QWidget* box, geopro::app::Glyph g) -> QToolButton* {
const int gid = static_cast<int>(g); const int gid = static_cast<int>(g);
@ -1278,19 +1316,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QStringLiteral("确定删除「%1」该操作不可撤销。").arg(name), QStringLiteral("确定删除「%1」该操作不可撤销。").arg(name),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No); QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (r == QMessageBox::Yes) nav.deleteObject(id, confType); if (r == QMessageBox::Yes) nav.deleteObject(id, confType);
} else if (action == QStringLiteral("edit")) {
// 动态表单编辑器:拉 project/getDynamicForm 真实 schema 渲染可编辑表单;
// 确定→校验+提交PUTbody 为推断结构,确切性以服务端为准)→成功刷新结构。
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->editObject(typeId, id, confType, name, objectTree->parentObjectId(id));
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("保存成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("newTm")) { } else if (action == QStringLiteral("newTm")) {
// 新建 TM对话框拉 tmList(全局方法类型)选类型 → getDynamicForm(type=2) → POST /tmObject。 // 新建 TM对话框拉 tmList(全局方法类型)选类型 → getDynamicForm(type=2) → POST /tmObject。
// 父对象:在 GS/项目根上=该节点;在 TM 上=其父 GS/根(即新建同级 TM // 父对象:在 GS/项目根上=该节点;在 TM 上=其父 GS/根(即新建同级 TM
@ -1354,7 +1379,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
[&window](const QString& msg) { [&window](const QString& msg) {
auto* sb = window.statusBar(); auto* sb = window.statusBar();
sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}") sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}")
.arg(QString::fromUtf8(geopro::app::semantic::kDanger))); .arg(geopro::app::token("status/danger")));
sb->showMessage(QStringLiteral("操作失败:%1").arg(msg), 6000); sb->showMessage(QStringLiteral("操作失败:%1").arg(msg), 6000);
QTimer::singleShot(6000, sb, [sb]() { sb->setStyleSheet(QString()); }); QTimer::singleShot(6000, sb, [sb]() { sb->setStyleSheet(QString()); });
}); });
@ -1399,16 +1424,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}); });
dlg->open(); dlg->open();
}; };
// 按选中类型决定菜单项:选 项目根/GS → 新建GS+TM选 TM → 仅新建TM同级 // 选 项目根/GS → 可新建 GS+TM。选中 TM 时按钮已被禁用(测线下不能新增对象),
// 父对象由 currentParentForNew() 统一给出(TM→父GS、GS/根→自身、未选→根),三种情况均正确 // 故此处仅处理非 TM父对象由 currentParentForNew() 给出GS/根→自身、未选→根)。
QMenu m(objectTree); QMenu m(objectTree);
if (objectTree->currentSelectedConfType() != 2) // 非 TM可新建检测对象(GS) m.addAction(QStringLiteral("新建检测对象"), objectTree,
m.addAction(QStringLiteral("新建检测对象"), objectTree, [openForm]() { openForm(true); });
[openForm]() { openForm(true); });
m.addAction(QStringLiteral("新建方法对象"), objectTree, m.addAction(QStringLiteral("新建方法对象"), objectTree,
[openForm]() { openForm(false); }); [openForm]() { openForm(false); });
m.exec(objAddBtn->mapToGlobal(QPoint(0, objAddBtn->height()))); m.exec(objAddBtn->mapToGlobal(QPoint(0, objAddBtn->height())));
}); });
// 选中 TM方法对象confType=2→ 禁用「新增」:测线下不能新增对象。
// 选根/GS 恢复可用;切项目/重载结构后选中清空,亦恢复可用。
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectSelectedForEdit, objAddBtn,
[objAddBtn](const QString&, int confType, const QString&, const QString&,
bool) { objAddBtn->setEnabled(confType != 2); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objAddBtn,
[objAddBtn](const QString&, const std::vector<geopro::data::StructNode>&) {
objAddBtn->setEnabled(true);
});
} }
// 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。 // 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。
@ -1635,7 +1668,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 状态栏错误反馈:临时染 danger 红8s 后随消息超时还原全局中性色。 // 状态栏错误反馈:临时染 danger 红8s 后随消息超时还原全局中性色。
auto* sb = window.statusBar(); auto* sb = window.statusBar();
sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}") sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}")
.arg(QString::fromUtf8(geopro::app::semantic::kDanger))); .arg(geopro::app::token("status/danger")));
sb->showMessage(QStringLiteral("加载失败(%1%2").arg(stage, msg), 8000); sb->showMessage(QStringLiteral("加载失败(%1%2").arg(stage, msg), 8000);
QTimer::singleShot(8000, sb, [sb]() { sb->setStyleSheet(QString()); }); QTimer::singleShot(8000, sb, [sb]() { sb->setStyleSheet(QString()); });
}); });
@ -1812,6 +1845,8 @@ int main(int argc, char* argv[])
// 数据详情仓储 + 控制器(接真实反演 API同一共享会话 ApiClient。 // 数据详情仓储 + 控制器(接真实反演 API同一共享会话 ApiClient。
geopro::data::ApiDatasetRepository datasetRepo(api); geopro::data::ApiDatasetRepository datasetRepo(api);
// 色阶模板仓储lvl 模板 + clr 色阶):同一共享会话 ApiClient注入 2D/3D 色阶编辑器。
geopro::data::ApiColorTemplateRepository colorTplRepo(api);
// 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。
geopro::controller::ChartStrategyRegistry chartRegistry; geopro::controller::ChartStrategyRegistry chartRegistry;
chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>()); chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>());
@ -1831,7 +1866,7 @@ int main(int argc, char* argv[])
// 启动防护:工作台构建期间任何同步加载失败(如样本数据缺失)都不应让进程静默退出, // 启动防护:工作台构建期间任何同步加载失败(如样本数据缺失)都不应让进程静默退出,
// 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。 // 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。
try { try {
buildWorkbench(*window, repo, projectRepo, datasetRepo, nav, detailCtrl); buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, nav, detailCtrl);
} catch (const std::exception& e) { } catch (const std::exception& e) {
QMessageBox::critical( QMessageBox::critical(
nullptr, QStringLiteral("启动失败"), nullptr, QStringLiteral("启动失败"),

View File

@ -54,6 +54,41 @@ QColor barColor(const QString& s)
return QColor(c.r, c.g, c.b); return QColor(c.r, c.g, c.b);
} }
// 异常分级 → 状态语义键(规范 §1.4/§6.3/§8.3:高=Danger 中=Warning 低=Info 未知=Neutral
// Anomaly 数据模型不带显式 level 字段,故按可得信号稳健推断:
// 1) typeName/name 含「高/中/低」字样 → 直接定级;
// 2) 否则按 lineColor 色相归类(红→高 橙/黄→中 蓝→低,与右栏列表/三维标注牌同色);
// 3) 仍无法判定 → Neutral停用/未知),避免乱给状态色。
// 返回值为状态 token 前缀("danger"/"warning"/"info"/"neutral"),调用方据此拼 token 名。
QString anomalyStatus(const geopro::core::Anomaly& a)
{
const QString tag = QString::fromStdString(a.typeName + a.name);
if (tag.contains(QStringLiteral(""))) return QStringLiteral("danger");
if (tag.contains(QStringLiteral(""))) return QStringLiteral("warning");
if (tag.contains(QStringLiteral(""))) return QStringLiteral("info");
// 按 lineColor 色相归类HSV 色相环红≈0/360 橙黄≈2070 蓝≈190260
const QColor c = barColor(QString::fromStdString(a.lineColor));
if (c.isValid() && c.saturationF() > 0.25) {
const int h = c.hue(); // -1=无色相(灰)
if (h >= 0) {
if (h < 20 || h >= 330) return QStringLiteral("danger");
if (h < 75) return QStringLiteral("warning");
if (h >= 185 && h < 265) return QStringLiteral("info");
}
}
return QStringLiteral("neutral");
}
// 状态键 + 后缀 → 主题 token 名(如 status + "danger" + "-bg"。neutral 无 -bg回落中性面。
QColor statusColor(const QString& status, bool bg)
{
if (status == QStringLiteral("neutral"))
return geopro::app::tokenColor(bg ? "bg/panel-subtle" : "status/neutral");
const QString name = QStringLiteral("status/%1%2").arg(status, bg ? QStringLiteral("-bg") : QString());
return geopro::app::tokenColor(name.toUtf8().constData());
}
// 右侧眼睛命中区(卡片右端,竖直居中)。 // 右侧眼睛命中区(卡片右端,竖直居中)。
QRect anomalyEyeRect(const QRect& itemRect) QRect anomalyEyeRect(const QRect& itemRect)
{ {
@ -95,14 +130,20 @@ public:
const bool selected = opt.state & QStyle::State_Selected; const bool selected = opt.state & QStyle::State_Selected;
const bool hover = opt.state & QStyle::State_MouseOver; const bool hover = opt.state & QStyle::State_MouseOver;
// 卡底hover/选中高亮) // 分级状态键§6.3):高=Danger 中=Warning 低=Info 未知=Neutral。
if (selected || hover) { const QString status = idx.data(kAnomalyStatusRole).toString();
QPainterPath path; path.addRoundedRect(r, 6, 6);
p->fillPath(path, geopro::app::tokenColor(selected ? "bg/selected" : "bg/hover")); // 卡底§6.3 规范§3.2 radius/md=6静止态 = 该分级状态浅底;选中/hover 叠一档
} // 交互态(选中=bg/selected 强调底hover=bg/hover让交互可辨又不丢分级语义。
// 左 3px 状态色竖条(取异常自身 lineColor QPainterPath path; path.addRoundedRect(r, geopro::app::radius::kMd, geopro::app::radius::kMd);
const QColor cardBg = selected ? geopro::app::tokenColor("bg/selected")
: hover ? geopro::app::tokenColor("bg/hover")
: statusColor(status, /*bg=*/true);
p->fillPath(path, cardBg);
// 左 3px 状态色竖条(取分级状态主色,与卡底/标签同源§6.3
p->fillRect(QRect(r.left(), r.top() + 4, 3, r.height() - 8), p->fillRect(QRect(r.left(), r.top() + 4, 3, r.height() - 8),
barColor(idx.data(kAnomalyColorRole).toString())); statusColor(status, /*bg=*/false));
const QString name = idx.data(Qt::DisplayRole).toString(); const QString name = idx.data(Qt::DisplayRole).toString();
const QString type = idx.data(kAnomalyTypeRole).toString(); const QString type = idx.data(kAnomalyTypeRole).toString();
@ -112,18 +153,24 @@ public:
const int right = anomalyEyeRect(opt.rect).left() - 8; // 给眼睛留位 const int right = anomalyEyeRect(opt.rect).left() - 8; // 给眼睛留位
const int rowW = right - left; const int rowW = right - left;
// 第一行:名称(加粗) // 第一行:状态圆点 + 名称text/body-strong 600。圆点 8px色 = 分级状态主色§6.3)。
const int dot = 8;
const QRect nameR(left + dot + 6, r.top() + 8, rowW - dot - 6, 20);
p->setBrush(statusColor(status, /*bg=*/false));
p->setPen(Qt::NoPen);
p->drawEllipse(QPointF(left + dot / 2.0, nameR.center().y()), dot / 2.0, dot / 2.0);
QFont nf = opt.font; nf.setPixelSize(geopro::app::scaledPx(13)); nf.setWeight(QFont::DemiBold); QFont nf = opt.font; nf.setPixelSize(geopro::app::scaledPx(13)); nf.setWeight(QFont::DemiBold);
p->setFont(nf); p->setFont(nf);
p->setPen(geopro::app::tokenColor("text/primary")); p->setPen(geopro::app::tokenColor("text/primary"));
const QRect nameR(left, r.top() + 8, rowW, 20); p->setBrush(Qt::NoBrush);
p->drawText(nameR, Qt::AlignLeft | Qt::AlignVCenter, p->drawText(nameR, Qt::AlignLeft | Qt::AlignVCenter,
p->fontMetrics().elidedText(name, Qt::ElideRight, rowW)); p->fontMetrics().elidedText(name, Qt::ElideRight, nameR.width()));
// 第二行:类型胶囊 + 摘要 // 第二行:分级胶囊标签 + 摘要
int x = left; int x = left;
const int cy = r.top() + 38; const int cy = r.top() + 38;
if (!type.isEmpty()) { if (!type.isEmpty()) {
// 等级标签§6.8):胶囊 radius/pill按高度算半径底 = 状态浅底,文字 = 状态主色。
QFont pf = opt.font; pf.setPixelSize(geopro::app::scaledPx(11)); QFont pf = opt.font; pf.setPixelSize(geopro::app::scaledPx(11));
p->setFont(pf); p->setFont(pf);
const QFontMetrics fm(pf); const QFontMetrics fm(pf);
@ -131,13 +178,16 @@ public:
const int ph = fm.height() + 2; const int ph = fm.height() + 2;
const QRect pill(x, cy - ph / 2, tw + 12, ph); const QRect pill(x, cy - ph / 2, tw + 12, ph);
QPainterPath pp; pp.addRoundedRect(pill, ph / 2.0, ph / 2.0); QPainterPath pp; pp.addRoundedRect(pill, ph / 2.0, ph / 2.0);
p->fillPath(pp, geopro::app::tokenColor("bg/hover")); p->fillPath(pp, statusColor(status, /*bg=*/true));
p->setPen(geopro::app::tokenColor("text/secondary")); p->setPen(statusColor(status, /*bg=*/false));
p->drawText(pill, Qt::AlignCenter, type); p->drawText(pill, Qt::AlignCenter, type);
x = pill.right() + 8; x = pill.right() + 8;
} }
if (!summary.isEmpty()) { if (!summary.isEmpty()) {
QFont sf = opt.font; sf.setPixelSize(geopro::app::scaledPx(11)); // 属性行数值§2.1/§6.3用等宽字族保证「140m · 18m / 32 Ω·m」逐列对齐。
QFont sf = opt.font;
sf.setFamilies(QString::fromLatin1(geopro::app::type::kMonoFamily).split(QStringLiteral(", ")));
sf.setPixelSize(geopro::app::scaledPx(11));
p->setFont(sf); p->setFont(sf);
p->setPen(geopro::app::tokenColor("text/secondary")); p->setPen(geopro::app::tokenColor("text/secondary"));
const QRect sumR(x, cy - 10, right - x, 20); const QRect sumR(x, cy - 10, right - x, 20);
@ -183,11 +233,25 @@ void populateAnomalyList(QListWidget* list, const std::vector<geopro::core::Anom
item->setData(kAnomalyColorRole, QString::fromStdString(a.lineColor)); item->setData(kAnomalyColorRole, QString::fromStdString(a.lineColor));
item->setData(kAnomalyTypeRole, QString::fromStdString(a.typeName)); item->setData(kAnomalyTypeRole, QString::fromStdString(a.typeName));
item->setData(kAnomalySummaryRole, summarize(a)); item->setData(kAnomalySummaryRole, summarize(a));
item->setData(kAnomalyStatusRole, anomalyStatus(a)); // 分级状态键(驱动卡底/标签/竖条同色)
item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(Qt::Checked); // 默认显示 item->setCheckState(Qt::Checked); // 默认显示
} }
} }
QString anomalyVisibleCountText(QListWidget* list)
{
if (!list) return QStringLiteral("0/0");
int total = 0, visible = 0;
for (int i = 0; i < list->count(); ++i) {
const QListWidgetItem* it = list->item(i);
if (!it) continue;
++total;
if (it->checkState() == Qt::Checked) ++visible;
}
return QStringLiteral("%1/%2").arg(visible).arg(total);
}
void applyAnomalyCardDelegate(QListWidget* list) void applyAnomalyCardDelegate(QListWidget* list)
{ {
if (!list) return; if (!list) return;

View File

@ -1,6 +1,8 @@
#pragma once #pragma once
#include <vector> #include <vector>
#include <QString>
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
class QListWidget; class QListWidget;
@ -14,6 +16,7 @@ constexpr int kAnomalyIndexRole = 0x0100; // Qt::UserRole
constexpr int kAnomalyColorRole = 0x0101; // lineColor 字符串 constexpr int kAnomalyColorRole = 0x0101; // lineColor 字符串
constexpr int kAnomalyTypeRole = 0x0102; // typeName constexpr int kAnomalyTypeRole = 0x0102; // typeName
constexpr int kAnomalySummaryRole = 0x0103; // 位置·深·尺寸 摘要 constexpr int kAnomalySummaryRole = 0x0103; // 位置·深·尺寸 摘要
constexpr int kAnomalyStatusRole = 0x0104; // 分级状态键danger/warning/info/neutral§6.3
// 给异常列表套用卡片委托(左色条+名称+类型标签+摘要+右侧显隐眼睛规范§6.3)。 // 给异常列表套用卡片委托(左色条+名称+类型标签+摘要+右侧显隐眼睛规范§6.3)。
void applyAnomalyCardDelegate(QListWidget* list); void applyAnomalyCardDelegate(QListWidget* list);
@ -24,4 +27,9 @@ void applyAnomalyCardDelegate(QListWidget* list);
// 清空旧条目后重填。 // 清空旧条目后重填。
void populateAnomalyList(QListWidget* list, const std::vector<geopro::core::Anomaly>& anomalies); void populateAnomalyList(QListWidget* list, const std::vector<geopro::core::Anomaly>& anomalies);
// 「异常列表 可见/总数」计数文本规范§6.3:标题计数=勾选可见数/总数,如 "2/3")。
// 供宿主面板表头/徽标显示——卡片列表本身不含标题栏,标题由外层 buildTabbedPanel 持有,
// 故此处只产出计数串,由调用方(监听列表勾选变化)写回标题或徽标。
QString anomalyVisibleCountText(QListWidget* list);
} // namespace geopro::app } // namespace geopro::app

View File

@ -32,7 +32,7 @@ DatasetAttrPanel::DatasetAttrPanel(geopro::data::IAsyncProjectRepository& repo,
auto* descLay = new QVBoxLayout(descBox); auto* descLay = new QVBoxLayout(descBox);
descLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm, descLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm,
geopro::app::space::kLg, geopro::app::space::kMd); geopro::app::space::kLg, geopro::app::space::kMd);
descLay->setSpacing(geopro::app::space::kXs); descLay->setSpacing(geopro::app::space::kMd); // 标签↔输入↔保存:统一 ≈8px 节奏(规范 §7.0
auto* hint = new QLabel(QStringLiteral("描述(备注)"), descBox); auto* hint = new QLabel(QStringLiteral("描述(备注)"), descBox);
geopro::app::applyTokenizedStyleSheet(hint, QStringLiteral("color:{{text/secondary}};")); geopro::app::applyTokenizedStyleSheet(hint, QStringLiteral("color:{{text/secondary}};"));

View File

@ -1,5 +1,7 @@
#include "panels/DatasetDetailPage.hpp" #include "panels/DatasetDetailPage.hpp"
#include <utility>
#include <QButtonGroup> #include <QButtonGroup>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -18,6 +20,12 @@ DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) {
lay->setSpacing(0); lay->setSpacing(0);
} }
void DatasetDetailPage::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo,
std::function<QString()> projectIdGetter) {
colorTplRepo_ = repo;
projectIdGetter_ = std::move(projectIdGetter);
}
void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName, void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName,
const std::vector<geopro::controller::TabSpec>& tabs) { const std::vector<geopro::controller::TabSpec>& tabs) {
Q_ASSERT(views_.empty()); // build() 仅一次views_ 为已 release 的裸指针,二次构建会泄漏旧视图 Q_ASSERT(views_.empty()); // build() 仅一次views_ 为已 release 的裸指针,二次构建会泄漏旧视图
@ -35,7 +43,9 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const
QVector<PanelTab> panelTabs; QVector<PanelTab> panelTabs;
for (size_t i = 0; i < tabs.size(); ++i) { for (size_t i = 0; i < tabs.size(); ++i) {
const auto& spec = tabs[i]; const auto& spec = tabs[i];
auto view = makeDetailView(spec.kind, this); // 抛出由调用栈兜底GuardedApplication // 仓储与 projectId 回调透传给工厂(仅 FilledContour 视图消费)。
auto view = makeDetailView(spec.kind, this, colorTplRepo_,
projectIdGetter_); // 抛出由调用栈兜底GuardedApplication
IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期 IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期
views_[i] = raw; views_[i] = raw;
// lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget随其尺寸覆盖图区 // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget随其尺寸覆盖图区

View File

@ -1,10 +1,16 @@
#pragma once #pragma once
#include <functional>
#include <vector> #include <vector>
#include <QMap> #include <QMap>
#include <QString>
#include <QVariant> #include <QVariant>
#include <QWidget> #include <QWidget>
#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec
namespace geopro::data {
class IColorTemplateRepository;
}
namespace geopro::app { namespace geopro::app {
class IDetailView; class IDetailView;
@ -17,6 +23,10 @@ class DatasetDetailPage : public QWidget {
public: public:
explicit DatasetDetailPage(QWidget* parent = nullptr); explicit DatasetDetailPage(QWidget* parent = nullptr);
// 色阶模板仓储 + projectId 取值回调(注入网格剖面色阶编辑器,须在 build 前设置)。
void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo,
std::function<QString()> projectIdGetter);
// 按页签集构建页签首次打开调一次。dsId/ddCode/dsName 用于 tabNeeded。 // 按页签集构建页签首次打开调一次。dsId/ddCode/dsName 用于 tabNeeded。
void build(const QString& dsId, const QString& ddCode, const QString& dsName, void build(const QString& dsId, const QString& ddCode, const QString& dsName,
const std::vector<geopro::controller::TabSpec>& tabs); const std::vector<geopro::controller::TabSpec>& tabs);
@ -49,6 +59,10 @@ private:
std::vector<bool> loaded_; // 各页签是否已加载(避免重复请求) std::vector<bool> loaded_; // 各页签是否已加载(避免重复请求)
std::vector<bool> requested_; // lazy 页签是否已请求过 std::vector<bool> requested_; // lazy 页签是否已请求过
QMap<int, LoadingOverlay*> overlays_; // lazy 页签的加载遮罩(覆盖该视图) QMap<int, LoadingOverlay*> overlays_; // lazy 页签的加载遮罩(覆盖该视图)
// 色阶模板仓储注入(透传给 makeDetailView → 网格剖面色阶编辑器)。
geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr;
std::function<QString()> projectIdGetter_;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,7 +1,16 @@
#include "panels/DatasetDetailPanel.hpp" #include "panels/DatasetDetailPanel.hpp"
#include <utility>
#include "panels/DatasetDetailPage.hpp" #include "panels/DatasetDetailPage.hpp"
namespace geopro::app { namespace geopro::app {
void DatasetDetailPanel::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo,
std::function<QString()> projectIdGetter) {
colorTplRepo_ = repo;
projectIdGetter_ = std::move(projectIdGetter);
}
DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) {
setTabsClosable(true); setTabsClosable(true);
connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); }); connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); });
@ -24,6 +33,8 @@ void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddC
auto* p = pageFor(dsId); auto* p = pageFor(dsId);
if (!p) { if (!p) {
p = new DatasetDetailPage(this); p = new DatasetDetailPage(this);
// 注入须在 build 前build 内造视图时即透传给工厂)。
p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_);
p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带
const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id
const int idx = addTab(p, title); const int idx = addTab(p, title);

View File

@ -1,8 +1,13 @@
#pragma once #pragma once
#include <functional>
#include <vector> #include <vector>
#include <QString>
#include <QTabWidget> #include <QTabWidget>
#include <QVariant> #include <QVariant>
#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec
namespace geopro::data {
class IColorTemplateRepository;
}
namespace geopro::app { namespace geopro::app {
class DatasetDetailPage; class DatasetDetailPage;
@ -12,6 +17,10 @@ class DatasetDetailPanel : public QTabWidget {
public: public:
explicit DatasetDetailPanel(QWidget* parent = nullptr); explicit DatasetDetailPanel(QWidget* parent = nullptr);
// 色阶模板仓储 + projectId 取值回调:透传给每个新建的详情页(网格剖面色阶编辑器用)。
void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo,
std::function<QString()> projectIdGetter);
// 数据集打开find-or-create 页 → build(tabs) → 加/抬该面板页签。 // 数据集打开find-or-create 页 → build(tabs) → 加/抬该面板页签。
void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
const std::vector<geopro::controller::TabSpec>& tabs); const std::vector<geopro::controller::TabSpec>& tabs);
@ -29,5 +38,9 @@ signals:
private: private:
DatasetDetailPage* pageFor(const QString& dsId) const; DatasetDetailPage* pageFor(const QString& dsId) const;
// 色阶模板仓储注入(新页 build 前 setColorTemplateRepo 透传)。
geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr;
std::function<QString()> projectIdGetter_;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -8,10 +8,12 @@
#include <QDateTimeEdit> #include <QDateTimeEdit>
#include <QDoubleValidator> #include <QDoubleValidator>
#include <QFormLayout> #include <QFormLayout>
#include <QFrame>
#include <QIntValidator> #include <QIntValidator>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QScrollArea>
#include <QSpinBox> #include <QSpinBox>
#include <QTimeEdit> #include <QTimeEdit>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -54,7 +56,7 @@ QString labelText(const data::EditField& f) {
QString t = QString::fromStdString(f.name); QString t = QString::fromStdString(f.name);
if (f.required == kRequiredYes) if (f.required == kRequiredYes)
t += QStringLiteral(" <span style='color:%1'>*</span>") t += QStringLiteral(" <span style='color:%1'>*</span>")
.arg(QString::fromUtf8(geopro::app::semantic::kDanger)); .arg(geopro::app::token("status/danger"));
return t; return t;
} }
@ -132,7 +134,7 @@ QWidget* buildWidget(const data::EditField& f) {
auto* te = new QPlainTextEdit(); auto* te = new QPlainTextEdit();
te->setPlainText(val); te->setPlainText(val);
te->setFixedHeight(geopro::app::scaledPx(64)); te->setFixedHeight(geopro::app::scaledPx(64));
if (ro) te->setReadOnly(true); if (ro) te->setEnabled(false); // 只读:灰显禁用
return te; return te;
} }
case kCompNumber: { case kCompNumber: {
@ -143,7 +145,7 @@ QWidget* buildWidget(const data::EditField& f) {
} else { } else {
applyIntRange(le, f); applyIntRange(le, f);
} }
if (ro) le->setReadOnly(true); if (ro) le->setEnabled(false); // 只读:灰显禁用(明确不可编辑,对齐原版 a-input disabled
return le; return le;
} }
case kCompText: case kCompText:
@ -158,7 +160,7 @@ QWidget* buildWidget(const data::EditField& f) {
} else if (f.dataType == kDtFloat) { } else if (f.dataType == kDtFloat) {
le->setValidator(new QDoubleValidator(le)); le->setValidator(new QDoubleValidator(le));
} }
if (ro) le->setReadOnly(true); if (ro) le->setEnabled(false); // 只读:灰显禁用(明确不可编辑,对齐原版 a-input disabled
return le; return le;
} }
} }
@ -202,6 +204,12 @@ bool widgetEmpty(int comp, QWidget* w) {
if (comp == kCompCheckbox) return false; // 复选框总有值 if (comp == kCompCheckbox) return false; // 复选框总有值
return readWidget(comp, w).trimmed().isEmpty(); return readWidget(comp, w).trimmed().isEmpty();
} }
// 限制字段控件最大宽(规范 §7.0.2「不要拉满」≈360px多行文本不限可更宽
void capFieldWidth(int comp, QWidget* w) {
if (comp == kCompMultiline) return; // 多行/长文本可更宽,不设上限
w->setMaximumWidth(geopro::app::scaledPx(geopro::app::space::kFormFieldMax));
}
} // namespace } // namespace
DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) { DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) {
@ -210,35 +218,109 @@ DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) {
lay->setSpacing(0); lay->setSpacing(0);
} }
void DynamicFormEditor::setForm(const data::EditableForm& form) { void DynamicFormEditor::clear() {
entries_.clear(); entries_.clear();
if (body_) { if (body_) {
body_->deleteLater(); body_->deleteLater();
body_ = nullptr; body_ = nullptr;
} }
}
void DynamicFormEditor::setForm(const data::EditableForm& form,
const QSet<QString>& hiddenFieldNames) {
clear();
body_ = new QWidget(this); body_ = new QWidget(this);
auto* outer = new QVBoxLayout(body_); auto* outer = new QVBoxLayout(body_);
outer->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, outer->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd,
geopro::app::space::kLg, geopro::app::space::kMd); geopro::app::space::kLg, geopro::app::space::kMd);
outer->setSpacing(geopro::app::space::kMd); outer->setSpacing(geopro::app::space::kMd);
// 用户修改任一可编辑控件 → changed()(脏标记)。值已在 buildWidget 内预填、连接在其后,
// 故初始化不会误触发;只读字段不挂(其值不应被视作用户编辑)。
auto wireChanged = [this](QWidget* w, int comp) {
switch (comp) {
case kCompCheckbox:
if (auto* x = qobject_cast<QCheckBox*>(w))
connect(x, &QCheckBox::toggled, this, [this] { emit changed(); });
break;
case kCompSelect:
case kCompTreeSelect:
if (auto* x = qobject_cast<QComboBox*>(w))
connect(x, &QComboBox::currentTextChanged, this, [this] { emit changed(); });
break;
case kCompDate:
if (auto* x = qobject_cast<QDateEdit*>(w))
connect(x, &QDateEdit::dateChanged, this, [this] { emit changed(); });
break;
case kCompTime:
if (auto* x = qobject_cast<QTimeEdit*>(w))
connect(x, &QTimeEdit::timeChanged, this, [this] { emit changed(); });
break;
case kCompDateTime:
if (auto* x = qobject_cast<QDateTimeEdit*>(w))
connect(x, &QDateTimeEdit::dateTimeChanged, this, [this] { emit changed(); });
break;
case kCompMultiline:
if (auto* x = qobject_cast<QPlainTextEdit*>(w))
connect(x, &QPlainTextEdit::textChanged, this, [this] { emit changed(); });
break;
default:
if (auto* x = qobject_cast<QLineEdit*>(w))
connect(x, &QLineEdit::textEdited, this, [this] { emit changed(); });
break;
}
};
bool renderedGroup = false; // 已渲染过组标题 → 后续组上方留 space/lg 分隔
for (const auto& g : form.groups) { for (const auto& g : form.groups) {
// 先分流:隐藏字段保留提交值但不渲染;可见字段进 visible 用于布局。
std::vector<const data::EditField*> visible;
for (const auto& f : g.fields) {
if (hiddenFieldNames.contains(QString::fromStdString(f.name))) {
Entry e;
e.code = QString::fromStdString(f.code);
e.name = QString::fromStdString(f.name);
e.comp = f.comp;
e.readonly = true;
e.hidden = true;
e.value = QString::fromStdString(f.value);
entries_.push_back(e);
} else {
visible.push_back(&f);
}
}
if (visible.empty()) continue; // 整组被隐藏 → 不渲染空标题
if (form.groups.size() > 1 || !g.name.empty()) { if (form.groups.size() > 1 || !g.name.empty()) {
// 分组标题(规范 §7.0.3 / §6.4text/heading 字号 + 加粗 + 次级色;非首组上留 space/lg。
if (renderedGroup) outer->addSpacing(geopro::app::space::kLg);
auto* sec = new QLabel(QString::fromStdString(g.name), body_); auto* sec = new QLabel(QString::fromStdString(g.name), body_);
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
sec, QStringLiteral("color:{{text/secondary}};font-weight:%1;") sec, QStringLiteral("color:{{text/secondary}};font-size:%1px;font-weight:%2;")
.arg(geopro::app::type::kHeading)
.arg(geopro::app::type::kWeightSemibold)); .arg(geopro::app::type::kWeightSemibold));
outer->addWidget(sec); outer->addWidget(sec);
// 标题下 1px divider 贯通(规范 §7.0.3)。
auto* rule = new QFrame(body_);
rule->setFrameShape(QFrame::HLine);
rule->setFixedHeight(1);
geopro::app::applyTokenizedStyleSheet(
rule, QStringLiteral("background:{{divider}};border:none;"));
outer->addWidget(rule);
} }
renderedGroup = true;
auto* fl = new QFormLayout(); auto* fl = new QFormLayout();
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
fl->setHorizontalSpacing(geopro::app::space::kMd); fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg
fl->setVerticalSpacing(geopro::app::space::kSm); fl->setVerticalSpacing(geopro::app::space::kMd); // 行距 ≈8px项目 kMd
for (const auto& f : g.fields) { for (const data::EditField* fp : visible) {
const data::EditField& f = *fp;
QWidget* w = buildWidget(f); QWidget* w = buildWidget(f);
capFieldWidth(f.comp, w); // 字段最大宽上限§7.0.2 不要拉满)
auto* lbl = new QLabel(labelText(f), body_); auto* lbl = new QLabel(labelText(f), body_);
lbl->setTextFormat(Qt::RichText); // 允许 * 的红色 span lbl->setTextFormat(Qt::RichText); // 允许 * 的红色 span
lbl->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kFormLabelCol)); // 等宽右标签列
fl->addRow(lbl, w); fl->addRow(lbl, w);
Entry e; Entry e;
e.code = QString::fromStdString(f.code); e.code = QString::fromStdString(f.code);
@ -248,6 +330,7 @@ void DynamicFormEditor::setForm(const data::EditableForm& form) {
e.readonly = isReadonly(f); e.readonly = isReadonly(f);
e.widget = w; e.widget = w;
entries_.push_back(e); entries_.push_back(e);
if (!e.readonly) wireChanged(w, f.comp); // 仅可编辑字段挂脏标记
} }
outer->addLayout(fl); outer->addLayout(fl);
} }
@ -257,7 +340,12 @@ void DynamicFormEditor::setForm(const data::EditableForm& form) {
QMap<QString, QString> DynamicFormEditor::collectValues() const { QMap<QString, QString> DynamicFormEditor::collectValues() const {
QMap<QString, QString> out; QMap<QString, QString> out;
for (const auto& e : entries_) out.insert(e.code, readWidget(e.comp, e.widget)); for (const auto& e : entries_) {
if (e.hidden || !e.widget)
out.insert(e.code, e.value); // 隐藏字段:回填原值,避免提交时被清空
else
out.insert(e.code, readWidget(e.comp, e.widget));
}
return out; return out;
} }
@ -272,4 +360,22 @@ bool DynamicFormEditor::validateRequired(QString* missingName) const {
return true; return true;
} }
void DynamicFormEditor::focusFirstInvalid() {
for (const auto& e : entries_) {
if (e.required && !e.readonly && e.widget && widgetEmpty(e.comp, e.widget)) {
e.widget->setFocus(Qt::OtherFocusReason);
// 滚动到可见:上层 QScrollArea 会响应 ensureWidgetVisible这里主动请求一次。
if (auto* p = e.widget->parentWidget()) p->updateGeometry();
QWidget* w = e.widget;
for (QWidget* a = w->parentWidget(); a; a = a->parentWidget()) {
if (auto* sa = qobject_cast<QScrollArea*>(a)) {
sa->ensureWidgetVisible(w);
break;
}
}
return;
}
}
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QMap> #include <QMap>
#include <QSet>
#include <QString> #include <QString>
#include <QVector> #include <QVector>
#include <QWidget> #include <QWidget>
@ -21,10 +22,18 @@ class DynamicFormEditor : public QWidget {
public: public:
explicit DynamicFormEditor(QWidget* parent = nullptr); explicit DynamicFormEditor(QWidget* parent = nullptr);
void setForm(const data::EditableForm& form); // 重建控件 // 重建控件。hiddenFieldNames按 fieldName 隐藏的字段(如与上层固定「名称」重复)——
// 不渲染,但其值仍参与 collectValues保留提交避免后端清空
void setForm(const data::EditableForm& form, const QSet<QString>& hiddenFieldNames = {});
void clear(); // 清空控件与状态(切换/加载前去残留)
QMap<QString, QString> collectValues() const; // fieldCode → 当前值 QMap<QString, QString> collectValues() const; // fieldCode → 当前值
// 校验必填:全部满足返回 true否则返回 false 并把首个缺失字段名写入 *missingName。 // 校验必填:全部满足返回 true否则返回 false 并把首个缺失字段名写入 *missingName。
bool validateRequired(QString* missingName) const; bool validateRequired(QString* missingName) const;
// 聚焦第一个「必填且为空」的可编辑控件并滚动可见(规范 §7.0.5:聚焦第一个错误字段)。
void focusFirstInvalid();
signals:
void changed(); // 任一可编辑控件被用户修改(上层据此启用「保存」脏标记)
private: private:
struct Entry { struct Entry {
@ -33,6 +42,8 @@ private:
int comp = 1; int comp = 1;
bool required = false; bool required = false;
bool readonly = false; // 只读comp2 / requiredType2 / 核心测量值):不参与必填校验 bool readonly = false; // 只读comp2 / requiredType2 / 核心测量值):不参与必填校验
bool hidden = false; // 去重隐藏:不渲染,但 value 仍参与收集提交
QString value; // hidden 时的固定提交值widget 为空)
QWidget* widget = nullptr; // 取值控件 QWidget* widget = nullptr; // 取值控件
}; };
QVector<Entry> entries_; QVector<Entry> entries_;

View File

@ -1,5 +1,6 @@
#include "panels/DynamicFormView.hpp" #include "panels/DynamicFormView.hpp"
#include <QFont>
#include <QFrame> #include <QFrame>
#include <QGridLayout> #include <QGridLayout>
#include <QLabel> #include <QLabel>
@ -19,23 +20,58 @@ constexpr int kColLabelB = 2;
constexpr int kColValueB = 3; constexpr int kColValueB = 3;
constexpr int kColSpanAll = 4; // 分组标题带横跨全部 4 列 constexpr int kColSpanAll = 4; // 分组标题带横跨全部 4 列
// 字段标签(次要色,右侧留点呼吸,顶对齐以配合值换行)。 constexpr int kKeyColWidth = 72; // 键列定宽§6.4「定宽约 72px」保证多组键列对齐
constexpr int kRowMinHeight = 28; // 字段行高§6.4「行高 28px」
// 判断值是否为「数值类」(坐标/数值/带单位/编号/日期):去掉常见数字标点、单位与
// 坐标符号后剩余字符多为数字则判定为数值类——这类值改用等宽字族逐列对齐§2.1/§6.4)。
// 纯文字(如名称、说明)不命中,保持默认字族。
bool isNumericValue(const QString& text)
{
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) return false;
int digits = 0;
int letters = 0;
for (const QChar c : trimmed) {
if (c.isDigit()) {
++digits;
} else if (c.isLetter()) {
// 允许少量单位/坐标字母E/N/W/S/m/z/Ω 等),但成片字母视为文字。
++letters;
}
}
// 至少含一位数字且数字不少于字母坐标「103.85°E·36.72°N」「140m」「z=1.0x」命中,
// 「华亭测区」「正常」这类纯文字不命中。
return digits > 0 && digits >= letters;
}
// 字段标签次要色右侧留点呼吸顶对齐以配合值换行。键列定宽以与各组对齐§6.4)。
QLabel* makeLabel(const QString& text) QLabel* makeLabel(const QString& text)
{ {
auto* k = new QLabel(text); auto* k = new QLabel(text);
k->setAlignment(Qt::AlignLeft | Qt::AlignTop); k->setAlignment(Qt::AlignLeft | Qt::AlignTop);
k->setFixedWidth(geopro::app::scaledPx(kKeyColWidth)); // 定宽 72px跨组对齐
k->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); // 行高 28px
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;")); k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;"));
return k; return k;
} }
// 字段值(主色、可换行、可选中复制)。 // 字段值(主色、可换行、可选中复制)。数值/坐标/带单位值改用等宽字族§6.4)。
QLabel* makeValue(const QString& text) QLabel* makeValue(const QString& text)
{ {
auto* v = new QLabel(text); auto* v = new QLabel(text);
v->setWordWrap(true); v->setWordWrap(true);
v->setAlignment(Qt::AlignLeft | Qt::AlignTop); v->setAlignment(Qt::AlignLeft | Qt::AlignTop);
v->setTextInteractionFlags(Qt::TextSelectableByMouse); v->setTextInteractionFlags(Qt::TextSelectableByMouse);
v->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); // 行高 28px
if (isNumericValue(text)) {
// 仅切字族(保留 QSS 给的颜色/字号):等宽保证逐列对齐。
QFont f = v->font();
// 必须用 setFamilies(列表)setFamily 把整串逗号名当成单一字族找不到→静默回退。
f.setFamilies(QString::fromLatin1(geopro::app::type::kMonoFamily).split(QStringLiteral(", ")));
v->setFont(f);
}
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;")); v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;"));
return v; return v;
@ -53,17 +89,19 @@ QFrame* makeRowDivider()
return line; return line;
} }
// 分组标题带:横跨整行的淡底强调条,半粗次要色,给表单清晰的层级。 // 分组标题带:横跨整行的淡底强调条,标题级字号 + 半粗给表单清晰的层级§6.4
// 「分组标题 text/heading上留 space/md」。上外边距用 space/md 与上一组拉开。
QLabel* makeGroupHeader(const QString& name) QLabel* makeGroupHeader(const QString& name)
{ {
auto* title = new QLabel(name); auto* title = new QLabel(name);
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
title, QStringLiteral("color:{{text/secondary}}; background:{{bg/hover}};" title, QStringLiteral("color:{{text/secondary}}; background:{{bg/hover}};"
"font-weight:%1; font-size:%2px;" "font-weight:%1; font-size:%2px;"
"border-radius:%3px; padding:5px 10px;") "border-radius:%3px; padding:5px 10px; margin-top:%4px;")
.arg(geopro::app::type::kWeightSemibold) .arg(geopro::app::type::kWeightSemibold)
.arg(geopro::app::scaledPx(geopro::app::type::kBody)) .arg(geopro::app::scaledPx(geopro::app::type::kHeading))
.arg(geopro::app::radius::kSm)); .arg(geopro::app::radius::kSm)
.arg(geopro::app::space::kMd));
return title; return title;
} }

View File

@ -9,6 +9,7 @@
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea> #include <QScrollArea>
#include <QSet>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp" #include "Theme.hpp"
@ -59,6 +60,10 @@ ObjectAttrPanel::ObjectAttrPanel(geopro::data::IAsyncProjectRepository& repo, QW
lay->addLayout(btnRow); lay->addLayout(btnRow);
QObject::connect(saveBtn_, &QPushButton::clicked, this, &ObjectAttrPanel::onSave); QObject::connect(saveBtn_, &QPushButton::clicked, this, &ObjectAttrPanel::onSave);
// 脏标记:动态字段被用户修改 → 启用「保存」(加载/未改时保持禁用)。
QObject::connect(editor_, &DynamicFormEditor::changed, this, [this] {
if (!objectId_.isEmpty()) saveBtn_->setEnabled(true);
});
// 初始:无选中 → 隐藏编辑区,仅占位提示。 // 初始:无选中 → 隐藏编辑区,仅占位提示。
scroll_->setVisible(false); scroll_->setVisible(false);
@ -88,27 +93,36 @@ void ObjectAttrPanel::loadObject(const QString& projectId, const QString& typeId
typeId_ = typeId; typeId_ = typeId;
parentId_ = parentId; parentId_ = parentId;
editor_->clear(); // 去残留:切换/加载期间不显示上一个对象的字段
if (typeId.isEmpty()) {
// 无类型 → getDynamicForm 必「数据不存在」;直接给友好占位,避免报错 + 残留旧表单。
showMessage(QStringLiteral("(该对象暂无可编辑属性)"));
return;
}
scroll_->setVisible(true); scroll_->setVisible(true);
saveBtn_->setVisible(true); saveBtn_->setVisible(true);
saveBtn_->setEnabled(false); saveBtn_->setEnabled(false); // 脏标记:未修改不可保存(等用户实际编辑)
status_->setText(QStringLiteral("加载中…")); status_->setText(QStringLiteral("加载中…"));
status_->setVisible(true); status_->setVisible(true);
rebuildTopFields(); rebuildTopFields();
if (nameEdit_) nameEdit_->setText(displayName); // 编辑态:名称预填(沿用对话框语义) if (nameEdit_) nameEdit_->setText(displayName); // 名称只读预填(与原版一致
if (formReq_) formReq_->abort(); if (formReq_) formReq_->abort();
formReq_ = repo_.loadEditableFormAsync(typeId.toStdString(), objectId.toStdString(), confType, formReq_ = repo_.loadEditableFormAsync(typeId.toStdString(), objectId.toStdString(), confType,
projectId_.toStdString()); projectId_.toStdString());
QObject::connect(formReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) { QObject::connect(formReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
const auto form = qvariant_cast<geopro::data::EditableForm>(v); const auto form = qvariant_cast<geopro::data::EditableForm>(v);
editor_->setForm(form); // 隐藏动态表单里与顶部固定「名称」重复的字段(值仍随 properties 提交,不丢)。
editor_->setForm(form, QSet<QString>{QStringLiteral("名称")});
status_->setVisible(false); status_->setVisible(false);
saveBtn_->setEnabled(true); // 不在此启用保存——保持禁用直到用户真正修改某字段changed 信号)。
}); });
QObject::connect(formReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) { QObject::connect(formReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载失败:%1").arg(msg)); // 失败:清空编辑区只留错误提示,避免残留上一个对象的属性。
status_->setVisible(true); showMessage(QStringLiteral("加载失败:%1").arg(msg));
}); });
} }
@ -131,23 +145,35 @@ void ObjectAttrPanel::rebuildTopFields() {
geopro::app::space::kLg, 0); geopro::app::space::kLg, 0);
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
fl->setHorizontalSpacing(geopro::app::space::kMd); fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg
fl->setVerticalSpacing(geopro::app::space::kSm); fl->setVerticalSpacing(geopro::app::space::kMd); // 行距 ≈8px与动态表单一致
// 顶部固定字段标签列与动态表单等宽对齐(规范 §7.0.2)。
auto addRow = [&](const QString& text, QLineEdit* edit) {
auto* lbl = new QLabel(text, topBox_);
lbl->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kFormLabelCol));
edit->setMaximumWidth(geopro::app::scaledPx(geopro::app::space::kFormFieldMax));
fl->addRow(lbl, edit);
};
nameEdit_ = new QLineEdit(topBox_); nameEdit_ = new QLineEdit(topBox_);
nameEdit_->setEnabled(false); // 编辑态名称禁用(与对话框一致) nameEdit_->setEnabled(false); // 编辑态名称禁用(与对话框一致)
fl->addRow(QStringLiteral("名称"), nameEdit_); addRow(QStringLiteral("名称"), nameEdit_);
if (confType_ == kConfGs) { if (confType_ == kConfGs) {
responsibleEdit_ = new QLineEdit(topBox_); responsibleEdit_ = new QLineEdit(topBox_);
responsibleEdit_->setPlaceholderText(QStringLiteral("负责人")); responsibleEdit_->setPlaceholderText(QStringLiteral("负责人"));
fl->addRow(QStringLiteral("负责人"), responsibleEdit_); // 负责人可编辑 → 纳入脏标记。
QObject::connect(responsibleEdit_, &QLineEdit::textEdited, this,
[this] { saveBtn_->setEnabled(true); });
addRow(QStringLiteral("负责人"), responsibleEdit_);
} }
} }
void ObjectAttrPanel::onSave() { void ObjectAttrPanel::onSave() {
QString missing; QString missing;
if (!editor_->validateRequired(&missing)) { if (!editor_->validateRequired(&missing)) {
editor_->focusFirstInvalid(); // 规范 §7.0.5:聚焦第一个错误字段
QMessageBox::warning(this, QStringLiteral("校验"), QMessageBox::warning(this, QStringLiteral("校验"),
QStringLiteral("请填写必填项:%1").arg(missing)); QStringLiteral("请填写必填项:%1").arg(missing));
return; return;
@ -182,7 +208,7 @@ void ObjectAttrPanel::onSave() {
confType_, false, QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString()); confType_, false, QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString());
QObject::connect(saveReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) { QObject::connect(saveReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) {
status_->setVisible(false); status_->setVisible(false);
saveBtn_->setEnabled(true); saveBtn_->setEnabled(false); // 保存成功 → 回到「无未保存修改」态
emit saved(); emit saved();
}); });
QObject::connect(saveReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) { QObject::connect(saveReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {

View File

@ -1,14 +1,19 @@
#include "panels/ObjectTreePanel.hpp" #include "panels/ObjectTreePanel.hpp"
#include <QEvent> #include <QEvent>
#include <QFont>
#include <QLabel> #include <QLabel>
#include <QMenu> #include <QMenu>
#include <QModelIndex> #include <QModelIndex>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter>
#include <QPoint> #include <QPoint>
#include <QRect>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QSize>
#include <QStringList> #include <QStringList>
#include <QStyle> #include <QStyle>
#include <QStyledItemDelegate>
#include <QStyleOptionViewItem> #include <QStyleOptionViewItem>
#include <QTimer> #include <QTimer>
#include <QTreeWidget> #include <QTreeWidget>
@ -27,9 +32,70 @@ constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 idGS/TM 都
constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM
constexpr int kRoleTypeId = Qt::UserRole + 4; // 类型 id编辑调 getDynamicForm 用) constexpr int kRoleTypeId = Qt::UserRole + 4; // 类型 id编辑调 getDynamicForm 用)
constexpr int kRoleIsRoot = Qt::UserRole + 5; // 项目根标记(按 GS 处理,但仅 新建GS/TM/属性) constexpr int kRoleIsRoot = Qt::UserRole + 5; // 项目根标记(按 GS 处理,但仅 新建GS/TM/属性)
constexpr int kRoleChildCount = Qt::UserRole + 6; // §6.1 计数徽标GS 直接子节点数(>0 才显示)
constexpr int kConfTypeGs = 1; // GS工区 constexpr int kConfTypeGs = 1; // GS工区
constexpr int kConfTypeTm = 2; // TM 叶子 constexpr int kConfTypeTm = 2; // TM 叶子
// §6.1 规范像素(随字号缩放,与全局 px 化 QSS 对齐)。
constexpr int kRowHeightBase = 28; // 行高 28px
constexpr int kIndentBase = 16; // 每级缩进 16px
constexpr int kTypeIconBase = 14; // 类型图标 14px
constexpr int kAccentBarBase = 2; // 选中行左侧 2px 竖条
// §6.1 对象树行委托:先调基类绘制标准单元(保留原生复选框指示区 + 选中底色 + 图标 + 文字,
// 故 SE_ItemViewItemCheckIndicator 命中检测不受影响),再叠加:
// ① 选中行左侧 2px accent/primary 竖条(覆盖层,不挤压文字);
// ② 右对齐计数徽标GS 子节点数text/caption + text/tertiary 色)。
// 行高经 sizeHint 固定为 scaledPx(28)选中行文字加粗600在 initStyleOption 注入。
class ObjectRowDelegate : public QStyledItemDelegate {
public:
using QStyledItemDelegate::QStyledItemDelegate;
QSize sizeHint(const QStyleOptionViewItem& opt, const QModelIndex& idx) const override {
QSize s = QStyledItemDelegate::sizeHint(opt, idx);
s.setHeight(scaledPx(kRowHeightBase));
return s;
}
protected:
// 选中行:名称用 text/body-strong字重 600。基类据此 option.font 绘制加粗文字。
void initStyleOption(QStyleOptionViewItem* opt, const QModelIndex& idx) const override {
QStyledItemDelegate::initStyleOption(opt, idx);
if (opt->state & QStyle::State_Selected) opt->font.setWeight(QFont::DemiBold);
}
public:
void paint(QPainter* painter, const QStyleOptionViewItem& opt,
const QModelIndex& idx) const override {
// ① 标准单元(复选框/图标/名称/选中底色)——必须先画,勿替换,否则复选框消失。
QStyledItemDelegate::paint(painter, opt, idx);
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
// ② 选中行左侧 2px accent 竖条(覆盖层,贴行左缘,不改文字位置)。
if (opt.state & QStyle::State_Selected) {
const int bar = scaledPx(kAccentBarBase);
painter->fillRect(QRect(opt.rect.left(), opt.rect.top(), bar, opt.rect.height()),
tokenColor("accent/primary"));
}
// ③ 右对齐计数徽标:仅 GS 且子节点数 >0。text/caption + text/tertiary。
const int count = idx.data(kRoleChildCount).toInt();
if (count > 0) {
QFont f = opt.font;
f.setPointSizeF(-1);
f.setPixelSize(scaledPx(type::kCaption));
f.setWeight(QFont::Normal);
painter->setFont(f);
painter->setPen(tokenColor("text/tertiary"));
QRect r = opt.rect.adjusted(0, 0, -scaledPx(space::kMd), 0);
painter->drawText(r, Qt::AlignRight | Qt::AlignVCenter, QString::number(count));
}
painter->restore();
}
};
// topLevel=true 仅用于项目根:按 GS 处理xlsx 第32行 + 真实数据 TM 挂根), // topLevel=true 仅用于项目根:按 GS 处理xlsx 第32行 + 真实数据 TM 挂根),
// 携带其 id/typeId可右键 新建GS/TM/属性;勾选随 2D/3D 批次暂不开放。 // 携带其 id/typeId可右键 新建GS/TM/属性;勾选随 2D/3D 批次暂不开放。
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes, void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes,
@ -39,19 +105,27 @@ void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNo
item->setText(0, QString::fromStdString(n.node.name)); item->setText(0, QString::fromStdString(n.node.name));
item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); item->setData(0, kRoleObjId, QString::fromStdString(n.node.id));
item->setData(0, kRoleTypeId, QString::fromStdString(n.node.typeId)); item->setData(0, kRoleTypeId, QString::fromStdString(n.node.typeId));
// §6.1 类型图标 14pxtext/secondary 色GS=工区(WorkArea)、TM=测线(SurveyLine)。
const int iconPx = scaledPx(kTypeIconBase);
const QColor iconColor = tokenColor("text/secondary");
if (topLevel) { if (topLevel) {
// 项目根:作为 GS 承载id 携带),不可勾选;菜单仅 新建GS/TM/属性。 // 项目根:作为 GS 承载id 携带),不可勾选;菜单仅 新建GS/TM/属性。
item->setData(0, kRoleConfType, kConfTypeGs); item->setData(0, kRoleConfType, kConfTypeGs);
item->setData(0, kRoleIsRoot, true); item->setData(0, kRoleIsRoot, true);
item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx));
} else if (n.isTm) { } else if (n.isTm) {
item->setData(0, kRoleConfType, kConfTypeTm); item->setData(0, kRoleConfType, kConfTypeTm);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(0, Qt::Unchecked); item->setCheckState(0, Qt::Unchecked);
item->setIcon(0, makeGlyph(Glyph::SurveyLine, iconColor, iconPx));
} else { } else {
item->setData(0, kRoleConfType, kConfTypeGs); // GS item->setData(0, kRoleConfType, kConfTypeGs); // GS
item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
item->setCheckState(0, Qt::Unchecked); item->setCheckState(0, Qt::Unchecked);
item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx));
} }
// §6.1 计数徽标GS含项目根显示直接子节点数TM 叶子无计数。
if (!n.isTm) item->setData(0, kRoleChildCount, static_cast<int>(n.children.size()));
addNodes(item, n.children, false); // 子层永远非顶层 addNodes(item, n.children, false); // 子层永远非顶层
} }
} }
@ -65,7 +139,10 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
// Qt 原生标准树:复选框/展开箭头由 Fusion 绘制(明暗都清晰);配色取自全局主题 QSS。 // Qt 原生标准树:复选框/展开箭头由 Fusion 绘制(明暗都清晰);配色取自全局主题 QSS。
tree_ = new QTreeWidget(this); tree_ = new QTreeWidget(this);
tree_->setHeaderHidden(true); tree_->setHeaderHidden(true);
tree_->setIndentation(14); // 收紧缩进 tree_->setIndentation(scaledPx(kIndentBase)); // §6.1 每级缩进 16px随字号缩放
tree_->setIconSize(QSize(scaledPx(kTypeIconBase), scaledPx(kTypeIconBase))); // 类型图标 14px
// §6.1 自定义行委托:行高 28px + 选中左竖条 + 右计数徽标 + 选中加粗(不破坏原生复选框)。
tree_->setItemDelegate(new ObjectRowDelegate(tree_));
lay->addWidget(tree_, 1); lay->addWidget(tree_, 1);
@ -149,16 +226,14 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
add(QStringLiteral("属性"), QStringLiteral("properties")); add(QStringLiteral("属性"), QStringLiteral("properties"));
add(QStringLiteral("异常详情"), QStringLiteral("exceptionDetail")); add(QStringLiteral("异常详情"), QStringLiteral("exceptionDetail"));
menu.addSeparator(); menu.addSeparator();
add(QStringLiteral("编辑"), QStringLiteral("edit")); // 「编辑」弹窗通道已移除GS/TM 统一走「属性」面板(右上对象属性)就地编辑,避免双编辑入口。
if (isGs) { if (isGs) {
// GS 节点:新建检测对象 / 新建方法对象。TM 节点上不显示「新建检测对象」——xlsxtm 上新建GS 无效。) // GS 节点:新建检测对象 / 新建方法对象。TM 节点上不显示「新建检测对象」——xlsxtm 上新建GS 无效。)
add(QStringLiteral("新建检测对象"), QStringLiteral("newGs")); add(QStringLiteral("新建检测对象"), QStringLiteral("newGs"));
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
} }
if (isTm) { if (isTm) {
// TM 节点:仅「新建方法对象」(同级,父=该 TM 的父 GS/根)+ 导入 DS。 // TM 节点:不提供任何「新建」(测线下不能新增对象)——仅「导入数据集」。
// xlsxtm 上新建GS 无效,故不显示「新建检测对象」。)
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
add(QStringLiteral("导入数据集…"), QStringLiteral("importDs")); add(QStringLiteral("导入数据集…"), QStringLiteral("importDs"));
} }
menu.addSeparator(); menu.addSeparator();

View File

@ -182,12 +182,13 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt
return QPointF(xMap.transform(p.x), yMap.transform(p.y)); return QPointF(xMap.transform(p.x), yMap.transform(p.y));
}; };
// 2) 等值线:黑色 0 宽cosmetic细线 // 2) 等值线:按线形⚙ 配置取色/虚实(默认黑实线)
if (showLines_ && !lines_.empty()) { if (showLines_ && !lines_.empty()) {
painter->save(); painter->save();
painter->setRenderHint(QPainter::Antialiasing, true); painter->setRenderHint(QPainter::Antialiasing, true);
QPen pen(QColor(0, 0, 0)); QPen pen(QColor(lineColor_.r, lineColor_.g, lineColor_.b, lineColor_.a));
pen.setWidthF(1.0); // 1px 黑色等值线 pen.setWidthF(1.0); // 1px 等值线
pen.setStyle(lineDashed_ ? Qt::DashLine : Qt::SolidLine);
painter->setPen(pen); painter->setPen(pen);
for (const auto& ln : lines_) { for (const auto& ln : lines_) {
if (ln.pts.size() < 2) continue; if (ln.pts.size() < 2) continue;
@ -205,7 +206,7 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt
QFont f = painter->font(); QFont f = painter->font();
f.setPixelSize(kLabelFontPx); f.setPixelSize(kLabelFontPx);
painter->setFont(f); painter->setFont(f);
painter->setPen(QColor(0, 0, 0)); painter->setPen(QColor(labelColor_.r, labelColor_.g, labelColor_.b, labelColor_.a));
const QFontMetricsF fm(f); const QFontMetricsF fm(f);
for (const auto& ln : lines_) { for (const auto& ln : lines_) {
if (ln.pts.size() < 2 || std::isnan(ln.level)) continue; if (ln.pts.size() < 2 || std::isnan(ln.level)) continue;

View File

@ -33,6 +33,10 @@ public:
void setShowLines(bool on) { showLines_ = on; } void setShowLines(bool on) { showLines_ = on; }
void setShowLabels(bool on) { showLabels_ = on; } void setShowLabels(bool on) { showLabels_ = on; }
void setShowAnomalies(bool on) { showAnomalies_ = on; } void setShowAnomalies(bool on) { showAnomalies_ = on; }
// 线形⚙ 配置(色阶编辑器下发):等值线色/线型(虚实)、标注色。默认黑实线。
void setLineColor(const core::Rgba& c) { lineColor_ = c; }
void setLineDashed(bool dashed) { lineDashed_ = dashed; }
void setLabelColor(const core::Rgba& c) { labelColor_ = c; }
int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; }
@ -53,6 +57,9 @@ private:
bool showLines_ = true; bool showLines_ = true;
bool showLabels_ = true; bool showLabels_ = true;
bool showAnomalies_ = true; bool showAnomalies_ = true;
core::Rgba lineColor_{0, 0, 0, 255}; // 等值线色(默认黑)
bool lineDashed_ = false; // 等值线虚实(默认实线)
core::Rgba labelColor_{0, 0, 0, 255}; // 标注色(默认黑)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,6 +1,7 @@
#include "panels/chart/DetailViewFactory.hpp" #include "panels/chart/DetailViewFactory.hpp"
#include <stdexcept> #include <stdexcept>
#include <utility>
#include "panels/chart/BarChartView.hpp" #include "panels/chart/BarChartView.hpp"
#include "panels/chart/DataTableView.hpp" #include "panels/chart/DataTableView.hpp"
@ -11,12 +12,18 @@
namespace geopro::app { namespace geopro::app {
std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget* parent) { std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget* parent,
geopro::data::IColorTemplateRepository* colorTplRepo,
std::function<QString()> projectIdGetter) {
switch (kind) { switch (kind) {
case controller::ViewKind::Scatter: case controller::ViewKind::Scatter:
return std::unique_ptr<IDetailView>(new RawDataChartView(parent)); return std::unique_ptr<IDetailView>(new RawDataChartView(parent));
case controller::ViewKind::FilledContour: case controller::ViewKind::FilledContour: {
return std::unique_ptr<IDetailView>(new GridDataChartView(parent)); auto* grid = new GridDataChartView(parent);
// 注入色阶模板仓储 + projectId 取值回调(网格剖面「色阶配置」编辑器用)。
grid->setColorTemplateRepo(colorTplRepo, std::move(projectIdGetter));
return std::unique_ptr<IDetailView>(grid);
}
case controller::ViewKind::Table: case controller::ViewKind::Table:
return std::unique_ptr<IDetailView>(new DataTableView(parent)); return std::unique_ptr<IDetailView>(new DataTableView(parent));
case controller::ViewKind::Bar: case controller::ViewKind::Bar:

View File

@ -1,9 +1,17 @@
#pragma once #pragma once
#include <functional>
#include <memory> #include <memory>
#include <QString>
#include "DatasetDetailTab.hpp" // geopro::controller::ViewKind #include "DatasetDetailTab.hpp" // geopro::controller::ViewKind
class QWidget; class QWidget;
namespace geopro::data {
class IColorTemplateRepository;
}
namespace geopro::app { namespace geopro::app {
class IDetailView; class IDetailView;
@ -11,6 +19,10 @@ class IDetailView;
// 按 render kind 造详情视图。E1b 仅支持 Scatter / FilledContour反演两页签 // 按 render kind 造详情视图。E1b 仅支持 Scatter / FilledContour反演两页签
// Bar/LineProfile/PolylineMap/Table 留待后续阶段measurement/trajectory/grid // Bar/LineProfile/PolylineMap/Table 留待后续阶段measurement/trajectory/grid
// 现阶段命中会抛 std::runtime_error明确失败而非静默空指针 // 现阶段命中会抛 std::runtime_error明确失败而非静默空指针
std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget* parent); // colorTplRepo/projectIdGetterFilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。
std::unique_ptr<IDetailView> makeDetailView(
controller::ViewKind kind, QWidget* parent,
geopro::data::IColorTemplateRepository* colorTplRepo = nullptr,
std::function<QString()> projectIdGetter = {});
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,7 +1,10 @@
#include "panels/chart/GridDataChartView.hpp" #include "panels/chart/GridDataChartView.hpp"
#include <utility>
#include <QCheckBox> #include <QCheckBox>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QSignalBlocker>
#include <QLabel> #include <QLabel>
#include <QSlider> #include <QSlider>
#include <QToolButton> #include <QToolButton>
@ -14,6 +17,7 @@
#include <QFrame> #include <QFrame>
#include <qwt_plot_rescaler.h> #include <qwt_plot_rescaler.h>
#include "ColorScaleConfigDialog.hpp"
#include "PanelHeader.hpp" #include "PanelHeader.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "panels/AnomalyTablePanel.hpp" #include "panels/AnomalyTablePanel.hpp"
@ -54,6 +58,7 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
auto* chkShowContourLabel = new QCheckBox(QStringLiteral("显示等值线标注"), toolbar); auto* chkShowContourLabel = new QCheckBox(QStringLiteral("显示等值线标注"), toolbar);
chkShowContourLabel->setChecked(true); chkShowContourLabel->setChecked(true);
chkShowLabels_ = chkShowContourLabel; // 存成员:线形⚙ 改标注显隐后回写复选框 UI
auto* chkContourTip = new QCheckBox(QStringLiteral("显示等值线提示信息"), toolbar); auto* chkContourTip = new QCheckBox(QStringLiteral("显示等值线提示信息"), toolbar);
chkContourTip->setChecked(false); chkContourTip->setChecked(false);
@ -165,6 +170,9 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
showLabels_ = on; showLabels_ = on;
if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); } if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); }
}); });
// 「色阶配置」→ 共享色阶编辑器(与三维体右键「色阶」同一对话框)。
connect(btnColorScale, &QToolButton::clicked, this,
[this]() { openColorScaleEditor(); });
// 主题配色:当前主题套一次 + 监听切换热更新。 // 主题配色:当前主题套一次 + 监听切换热更新。
applyChartPlotTheme(plot_); applyChartPlotTheme(plot_);
@ -217,8 +225,11 @@ void GridDataChartView::rebuildContour() {
} }
contourItem_ = new ContourPlotItem(); contourItem_ = new ContourPlotItem();
contourItem_->setData(grid_, colorSvc_, anoms_, /*showLines*/ true, showLabels_); contourItem_->setData(grid_, colorSvc_, anoms_, lineCfg_.lineShow, showLabels_);
contourItem_->setShowAnomalies(showAnomalies_); contourItem_->setShowAnomalies(showAnomalies_);
contourItem_->setLineColor(lineCfg_.lineColor); // 线形⚙ 配置
contourItem_->setLineDashed(lineCfg_.dashed);
contourItem_->setLabelColor(lineCfg_.labelColor);
contourItem_->attach(plot_); contourItem_->attach(plot_);
// 轴范围 = 数据范围x=距离、y=深度/高程)。 // 轴范围 = 数据范围x=距离、y=深度/高程)。
@ -232,4 +243,35 @@ void GridDataChartView::rebuildContour() {
plot_->replot(); plot_->replot();
} }
void GridDataChartView::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo,
std::function<QString()> projectIdGetter) {
tplRepo_ = repo;
projectIdGetter_ = std::move(projectIdGetter);
}
void GridDataChartView::openColorScaleEditor() {
if (!hasGrid_) return;
// 数据范围始终取网格真实值域(不取编辑后色阶端点,否则等积兜底/新增插值会用错区间)。
std::vector<double> samples = grid_.values(); // 等积分层用原始标量
// projectId 在打开时取一次(随项目切换生效);无 getter 退化为空 → 后端按钮禁用。
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
ColorScaleConfigDialog dlg(gridScale_, grid_.vmin, grid_.vmax, std::move(samples), lineCfg_,
tplRepo_, projectId, this);
if (dlg.exec() != QDialog::Accepted) return;
gridScale_ = dlg.colorScale();
lineCfg_ = dlg.lineConfig();
showLabels_ = lineCfg_.labelShow; // 标注显隐同步 + 回写工具条复选框(避免 UI 与状态脱钩)
if (chkShowLabels_) {
const QSignalBlocker block(chkShowLabels_);
chkShowLabels_->setChecked(showLabels_);
}
delete colorSvc_;
colorSvc_ = new ColorMapService(gridScale_);
rebuildContour();
colorBar_->setColorScale(gridScale_);
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,6 +1,8 @@
#pragma once #pragma once
#include <functional>
#include <vector> #include <vector>
#include <QString>
#include <QWidget> #include <QWidget>
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
@ -8,12 +10,18 @@
#include "model/Field.hpp" #include "model/Field.hpp"
#include "model/detail/DetailPayloads.hpp" #include "model/detail/DetailPayloads.hpp"
#include "panels/chart/IDetailView.hpp" #include "panels/chart/IDetailView.hpp"
#include "ContourLineDialog.hpp" // ContourLineConfig线形/标注状态)
class QSlider; class QSlider;
class QLabel; class QLabel;
class QCheckBox;
class QwtPlot; class QwtPlot;
class QwtPlotRescaler; class QwtPlotRescaler;
namespace geopro::data {
class IColorTemplateRepository;
}
namespace geopro::app { namespace geopro::app {
class AnomalyTablePanel; class AnomalyTablePanel;
@ -39,8 +47,14 @@ public:
QWidget* widget() override { return this; } QWidget* widget() override { return this; }
void setPayload(const QVariant& payload) override; void setPayload(const QVariant& payload) override;
// 注入色阶模板仓储 + 当前 projectId 取值回调(打开编辑器时取一次,随项目切换生效)。
// 可传空仓储 → 编辑器内「另存为/打开」「新建色阶」禁用。
void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo,
std::function<QString()> projectIdGetter);
private: private:
void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem
void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙)
QwtPlot* plot_ = nullptr; QwtPlot* plot_ = nullptr;
QwtPlotRescaler* rescaler_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr;
@ -49,6 +63,7 @@ private:
DescriptionPanel* descriptionPanel_ = nullptr; DescriptionPanel* descriptionPanel_ = nullptr;
QSlider* simplifySlider_ = nullptr; QSlider* simplifySlider_ = nullptr;
QLabel* simplifyValueLabel_ = nullptr; QLabel* simplifyValueLabel_ = nullptr;
QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步)
// 渲染状态 // 渲染状态
ColorMapService* colorSvc_ = nullptr; // heapsetGridData 重建 ColorMapService* colorSvc_ = nullptr; // heapsetGridData 重建
@ -61,6 +76,11 @@ private:
// 工具条显隐开关 // 工具条显隐开关
bool showAnomalies_ = true; bool showAnomalies_ = true;
bool showLabels_ = true; bool showLabels_ = true;
ContourLineConfig lineCfg_; // 线形/标注配置(色阶编辑器 线形⚙ 下发)
// 色阶模板仓储 + projectId 取值回调(注入;空则编辑器后端按钮禁用)。
geopro::data::IColorTemplateRepository* tplRepo_ = nullptr;
std::function<QString()> projectIdGetter_;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -24,6 +24,8 @@ public:
virtual void clear() = 0; virtual void clear() = 0;
virtual void setVerticalExaggeration(double ve) = 0; virtual void setVerticalExaggeration(double ve) = 0;
// 地表高程基准测线地表高程2D 足迹「顶部/底部」摆放锚定真实地表。
virtual double zRefElev() const = 0;
// 2D俯视测线红线z=0 // 2D俯视测线红线z=0
virtual void addSurveyLine(const geopro::core::Grid& grid) = 0; virtual void addSurveyLine(const geopro::core::Grid& grid) = 0;
@ -33,6 +35,9 @@ public:
// 3D体绘制IDW 体素 + colorScale按 dsId 跟踪。 // 3D体绘制IDW 体素 + colorScale按 dsId 跟踪。
virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) = 0; const geopro::core::ColorScale& cs) = 0;
// 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图worldZ=摆放高程);按 dsId 跟踪以支持增量移除。
virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) = 0;
// 3DDEM 地形 + 影像纹理。 // 3DDEM 地形 + 影像纹理。
virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0; virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0;
// 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。 // 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。

View File

@ -11,6 +11,13 @@
namespace geopro::controller { namespace geopro::controller {
namespace {
// 二维足迹「顶部/底部」摆放相对参考高程(Z=0)的偏移(米):控制器无地形/参考高程源
// (地形异步、帘面经纬未必到场),故退化为 Z=0 上/下固定偏移,使足迹不与帘面顶/底面重叠遮挡。
constexpr double kTopOffsetZ = 50.0; // 顶部:参考面上方
constexpr double kBottomOffsetZ = -50.0; // 底部:参考面下方
} // namespace
VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo, VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo,
data::I3dSceneRepository& sceneRepo, I3dSceneView& view, data::I3dSceneRepository& sceneRepo, I3dSceneView& view,
QObject* parent) QObject* parent)
@ -46,6 +53,84 @@ void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算 view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算
} }
void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) {
std::vector<std::string> newDs;
newDs.reserve(static_cast<std::size_t>(dsIds.size()));
for (const QString& id : dsIds) newDs.push_back(id.toStdString());
// 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff不全量重建不打断 3D 帘面/体)。
const std::set<std::string> oldSet(checked2dDs_.begin(), checked2dDs_.end());
const std::set<std::string> newSet(newDs.begin(), newDs.end());
for (const auto& id : checked2dDs_)
if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元
checked2dDs_ = std::move(newDs);
fitOnArrival_ = false; // 足迹增量追加:保持当前相机不跳
// 足迹画进 View3D 场景mode=0 关闭 → 仅记录勾选不渲染(见 set2DPlacement 切回时补画)。
if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) {
const unsigned long long gen = rebuildGeneration_; // 不自增:与 3D 增量互不作废
for (const auto& id : checked2dDs_)
if (!oldSet.count(id)) add2DDatasetAsync(id, gen); // 新增 → 异步取足迹增量入场
}
view_.renderIncremental(); // 立即反映移除
}
void VtkSceneController::set2DPlacement(int mode, double customZ) {
const bool changed = (mode != placement2dMode_) || (mode == 4 && customZ != customZ2d_);
placement2dMode_ = mode;
customZ2d_ = customZ;
if (!changed || checked2dDs_.empty()) return;
// 摆放变化 → 对已勾选足迹重摆:先全部移除,再按新 Z 重加mode=0 关闭则只移除不重加)。
for (const auto& id : checked2dDs_) view_.removeDataset(id);
if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) {
const unsigned long long gen = rebuildGeneration_;
fitOnArrival_ = false; // 重摆:保持相机
for (const auto& id : checked2dDs_) add2DDatasetAsync(id, gen);
}
view_.renderIncremental();
}
double VtkSceneController::placementZ() const {
const double surf = view_.zRefElev(); // 真实地表高程基准(测线地表高程)
switch (placement2dMode_) {
case 1: return 0.0; // Z=0世界原点
case 2: return surf + kTopOffsetZ; // 顶部:贴真实地表上方
case 3: return surf + kBottomOffsetZ; // 底部:真实地表下方
case 4: return customZ2d_; // 自定义
default: return 0.0; // 关闭(0) 不应走到此(调用方拦截)
}
}
void VtkSceneController::add2DDatasetAsync(const std::string& dsId, unsigned long long gen) {
if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求
loadingDs_.insert(dsId);
QPointer<VtkSceneController> self(this);
sceneRepo_.loadMapLine(
dsId,
[self, gen, dsId](data::MapLine line) {
if (!self) return;
self->loadingDs_.erase(dsId);
// gen 作废 / 已取消勾选 / 摆放已关闭 → 丢弃迟到回调。
if (gen != self->rebuildGeneration_ || !self->is2DChecked(dsId) ||
self->placement2dMode_ == 0) {
return;
}
// 落地时按当前摆放 Z非请求时快照→ 加载期间摆放变化也取最新高程。
self->view_.addMapLine(dsId, line, self->placementZ());
self->onDatasetArrived();
},
[self, gen, dsId](const std::string& m) {
if (!self) return;
self->loadingDs_.erase(dsId);
if (gen != self->rebuildGeneration_) return;
emit self->loadFailed(QString::fromStdString(m));
});
}
void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long long gen) { void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long long gen) {
if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求 if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求
QPointer<VtkSceneController> self(this); QPointer<VtkSceneController> self(this);
@ -109,6 +194,10 @@ bool VtkSceneController::isChecked(const std::string& dsId) const {
return std::find(checkedDs_.begin(), checkedDs_.end(), dsId) != checkedDs_.end(); return std::find(checkedDs_.begin(), checkedDs_.end(), dsId) != checkedDs_.end();
} }
bool VtkSceneController::is2DChecked(const std::string& dsId) const {
return std::find(checked2dDs_.begin(), checked2dDs_.end(), dsId) != checked2dDs_.end();
}
void VtkSceneController::setViewMode(ViewMode mode) { void VtkSceneController::setViewMode(ViewMode mode) {
mode_ = mode; mode_ = mode;
rebuildInternal(); rebuildInternal();
@ -130,6 +219,19 @@ void VtkSceneController::setVerticalExaggeration(double ve) {
void VtkSceneController::rebuild() { rebuildInternal(); } void VtkSceneController::rebuild() { rebuildInternal(); }
void VtkSceneController::setVolumeColorScale(const std::string& dsId,
const geopro::core::ColorScale& cs) {
volumeScaleCache_[dsId] = cs; // 会话级 mock 持久(再勾选命中缓存,见 addDatasetAsync
if (!isChecked(dsId)) return; // 未渲染 → 仅更缓存,下次勾选生效
auto git = volumeCache_.find(dsId);
if (git == volumeCache_.end()) return; // 体网格尚未到场 → 同上
// 移除旧体素 → 以新色阶重建addVolume 内部置 currentColorScale_ 并触发 onVolumeChanged
// InteractionManager 据此以新色阶重建该体下已勾选切片。
view_.removeDataset(dsId);
view_.addVolume(dsId, git->second, cs);
view_.renderIncremental();
}
void VtkSceneController::setAxesMode(AxesMode mode) { void VtkSceneController::setAxesMode(AxesMode mode) {
axesMode_ = mode; axesMode_ = mode;
rebuildInternal(); // 坐标轴随场景重建clear 会移除旧坐标轴 prop rebuildInternal(); // 坐标轴随场景重建clear 会移除旧坐标轴 prop
@ -190,6 +292,9 @@ void VtkSceneController::rebuildInternal() {
}); });
} }
for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen); for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen);
// 二维足迹随全量重建一并重画clear 已移除其图元mode=0 关闭则跳过。
if (placement2dMode_ != 0)
for (const auto& dsId : checked2dDs_) add2DDatasetAsync(dsId, gen);
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
emit loadFailed(QString::fromStdString(e.what())); emit loadFailed(QString::fromStdString(e.what()));

View File

@ -37,11 +37,19 @@ public:
public slots: public slots:
void setCheckedDatasets(const QStringList& dsIds); void setCheckedDatasets(const QStringList& dsIds);
// 二维数据集栏勾选(足迹型测线/轨迹)→ 平铺进 View3D 地图。与 3D 勾选集独立,按 dsId 增量。
void setChecked2DDatasets(const QStringList& dsIds);
// 二维足迹摆放高度mode0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义customZ 仅 mode=4 用)。
void set2DPlacement(int mode, double customZ);
void setViewMode(ViewMode mode); void setViewMode(ViewMode mode);
void setLayer(SceneLayer layer, bool on); void setLayer(SceneLayer layer, bool on);
void setVerticalExaggeration(double ve); void setVerticalExaggeration(double ve);
void rebuild(); // 主题切换等外部触发的重渲染 void rebuild(); // 主题切换等外部触发的重渲染
// 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。
// 后端 3D 色阶保存未就绪 → 缓存即会话级 mock 持久(再勾选命中 volumeScaleCache_
void setVolumeColorScale(const std::string& dsId, const geopro::core::ColorScale& cs);
// ── P2 三维数据集栏 ── // ── P2 三维数据集栏 ──
void setAxesMode(AxesMode mode); void setAxesMode(AxesMode mode);
void setAxesUnit(AxesUnit unit); void setAxesUnit(AxesUnit unit);
@ -57,14 +65,24 @@ private:
void rebuildInternal(); void rebuildInternal();
// 增量加入单个 ds帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。 // 增量加入单个 ds帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。
void addDatasetAsync(const std::string& dsId, unsigned long long gen); void addDatasetAsync(const std::string& dsId, unsigned long long gen);
// 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 当前摆放 Z回调按 gen + 仍勾选 守护。
void add2DDatasetAsync(const std::string& dsId, unsigned long long gen);
void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景 void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景
bool isChecked(const std::string& dsId) const; bool isChecked(const std::string& dsId) const;
bool is2DChecked(const std::string& dsId) const;
// 当前摆放模式下足迹的世界 Zmode 0=关闭由调用方拦截;此处算 1/2/3/4 的 Z
double placementZ() const;
data::IDatasetRepository& dsRepo_; data::IDatasetRepository& dsRepo_;
data::I3dSceneRepository& sceneRepo_; data::I3dSceneRepository& sceneRepo_;
I3dSceneView& view_; I3dSceneView& view_;
std::vector<std::string> checkedDs_; std::vector<std::string> checkedDs_;
// 二维足迹勾选集(与 checkedDs_ 独立;都画进 View3D 场景)。按 dsId 增量加/删。
std::vector<std::string> checked2dDs_;
// 二维足迹摆放mode 0关闭/1 Z=0/2顶部/3底部/4自定义customZ2d_ 仅 mode=4 用。
int placement2dMode_ = 0;
double customZ2d_ = 0.0;
ViewMode mode_ = ViewMode::Map2D; ViewMode mode_ = ViewMode::Map2D;
bool showCurtain_ = true; bool showCurtain_ = true;
bool showVoxel_ = false; bool showVoxel_ = false;

View File

@ -12,6 +12,7 @@ add_library(geopro_data STATIC
dto/GridDto.cpp dto/GridDto.cpp
api/ApiProjectRepository.cpp api/ApiProjectRepository.cpp
api/ApiDatasetRepository.cpp api/ApiDatasetRepository.cpp
api/ApiColorTemplateRepository.cpp
api/Api3dRepository.cpp api/Api3dRepository.cpp
api/DatasetLoadHandles.cpp api/DatasetLoadHandles.cpp
api/NavRequest.cpp) api/NavRequest.cpp)

View File

@ -36,7 +36,11 @@ DsDimension Api3dRepository::dimensionOf(const DsRow& ds) const {
return DsDimension::Dim3D; return DsDimension::Dim3D;
} }
if (c == "dd_slice") return DsDimension::Analysis3D; if (c == "dd_slice") return DsDimension::Analysis3D;
if (c == "dd_trajectory_data") return DsDimension::Dim2D; // 足迹型(测线/各类轨迹) → 二维数据集:地面 lat/lon 序列平铺进地图spec §4.1/§4.2)。
if (c == "dd_trajectory_data" || c == "dd_transient_electromagnetic_trajectory_data" ||
c == "dd_radar_channel_trajectory" || c == "dd_radar_rtk_trajectory") {
return DsDimension::Dim2D;
}
return DsDimension::Other; return DsDimension::Other;
} }
@ -64,6 +68,34 @@ void Api3dRepository::loadSection(const std::string& dsId, std::function<void(Se
}); });
} }
void Api3dRepository::loadMapLine(const std::string& dsId, std::function<void(MapLine)> onOk,
OnError onErr) {
// 真实足迹:复用 ApiDatasetRepository 轨迹地图端点(loaderKey="traj.map" → dd/ert/trajectory/line
// frontCrsCode 固定 EPSG:4326)。命中载荷 = core::MapPayload{points[].lat/lon};取经纬填 MapLine。
DetailLoad* load = dsRepo_.loadAsync("traj.map", dsId);
if (load == nullptr) {
onErr("Api3dRepository::loadMapLine: loadAsync 返回空句柄");
return;
}
// 以 load 为连接上下文 → 它 deleteLater 时自动断开(与 loadSection 同范式)。
QObject::connect(load, &DetailLoad::done, load,
[onOk = std::move(onOk)](const QVariant& payload) {
const auto mp = qvariant_cast<core::MapPayload>(payload);
MapLine line;
line.lat.reserve(mp.points.size());
line.lon.reserve(mp.points.size());
for (const auto& p : mp.points) {
line.lat.push_back(p.lat);
line.lon.push_back(p.lon);
}
onOk(std::move(line));
});
QObject::connect(load, &DetailLoad::failed, load,
[onErr = std::move(onErr)](const QString& message) {
onErr(message.toStdString());
});
}
bool Api3dRepository::isVolumeDataset(const std::string& dsId) const { bool Api3dRepository::isVolumeDataset(const std::string& dsId) const {
return volumes_.find(dsId) != volumes_.end(); return volumes_.find(dsId) != volumes_.end();
} }

View File

@ -67,6 +67,8 @@ public:
OnError onErr) override; OnError onErr) override;
void loadSection(const std::string& dsId, std::function<void(SectionData)> onOk, void loadSection(const std::string& dsId, std::function<void(SectionData)> onOk,
OnError onErr) override; OnError onErr) override;
void loadMapLine(const std::string& dsId, std::function<void(MapLine)> onOk,
OnError onErr) override;
void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) override; void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) override;
// 切片 CRUD后端未就绪 → 变更走 onErr // 切片 CRUD后端未就绪 → 变更走 onErr

View File

@ -0,0 +1,97 @@
#include "api/ApiColorTemplateRepository.hpp"
#include <utility>
#include <QPointer>
#include <QUrl>
#include "ApiClient.hpp"
#include "IApiCall.hpp"
namespace geopro::data {
namespace {
// 失败判定与详情仓储一致:业务码 != 200 或传输错误rawError 非空)。
bool isOk(const net::ApiResponse& r) { return r.code == 200 && r.rawError.isEmpty(); }
} // namespace
ApiColorTemplateRepository::ApiColorTemplateRepository(net::ApiClient& api) : api_(api) {}
void ApiColorTemplateRepository::saveLvlTemplate(const QString& projectId,
const QString& templateName,
const QJsonObject& properties,
std::function<void(bool, QString)> cb) {
QJsonObject body{{QStringLiteral("projectId"), projectId},
{QStringLiteral("templateName"), templateName},
{QStringLiteral("properties"), properties}};
auto* call = api_.postJsonAsync(QStringLiteral("/business/lvlTemplate"), body);
if (call == nullptr) {
if (cb) cb(false, QStringLiteral("请求创建失败"));
return;
}
QObject::connect(call, &net::IApiCall::finished, call,
[cb = std::move(cb)](const net::ApiResponse& resp) {
if (cb) cb(isOk(resp), resp.msg);
});
}
void ApiColorTemplateRepository::listLvlTemplates(
const QString& projectId, std::function<void(bool, QJsonArray, QString)> cb) {
QJsonObject body{{QStringLiteral("projectId"), projectId},
{QStringLiteral("pageNo"), 1},
{QStringLiteral("pageSize"), 1000}};
auto* call = api_.postJsonAsync(QStringLiteral("/business/lvlTemplate/page"), body);
if (call == nullptr) {
if (cb) cb(false, {}, QStringLiteral("请求创建失败"));
return;
}
QObject::connect(call, &net::IApiCall::finished, call,
[cb = std::move(cb)](const net::ApiResponse& resp) {
if (!cb) return;
if (!isOk(resp)) {
cb(false, {}, resp.msg);
return;
}
// page 型data 为对象,列表在 data.list。
cb(true, resp.data.value(QStringLiteral("list")).toArray(), resp.msg);
});
}
void ApiColorTemplateRepository::newClrScheme(const QString& projectId,
const QJsonObject& properties,
std::function<void(bool, QString)> cb) {
QJsonObject body{{QStringLiteral("projectId"), projectId},
{QStringLiteral("properties"), properties}};
auto* call = api_.postJsonAsync(QStringLiteral("/business/clr/colorGradation"), body);
if (call == nullptr) {
if (cb) cb(false, QStringLiteral("请求创建失败"));
return;
}
QObject::connect(call, &net::IApiCall::finished, call,
[cb = std::move(cb)](const net::ApiResponse& resp) {
if (cb) cb(isOk(resp), resp.msg);
});
}
void ApiColorTemplateRepository::listClrSchemes(
const QString& projectId, std::function<void(bool, QJsonArray, QString)> cb) {
auto* call = api_.getAsync(
QStringLiteral("/business/clr/colorGradation/queryCLRColorGradation/%1")
.arg(QString::fromUtf8(QUrl::toPercentEncoding(projectId))));
if (call == nullptr) {
if (cb) cb(false, {}, QStringLiteral("请求创建失败"));
return;
}
QObject::connect(call, &net::IApiCall::finished, call,
[cb = std::move(cb)](const net::ApiResponse& resp) {
if (!cb) return;
if (!isOk(resp)) {
cb(false, {}, resp.msg);
return;
}
// clr 查询data 顶层为数组buildResponse 包成 {"value": [...]}。
cb(true, resp.data.value(QStringLiteral("value")).toArray(), resp.msg);
});
}
} // namespace geopro::data

View File

@ -0,0 +1,29 @@
#pragma once
#include "repo/IColorTemplateRepository.hpp"
namespace geopro::net { class ApiClient; }
namespace geopro::data {
// IColorTemplateRepository 的真实 API 实现lvl 模板 + clr 色阶)。
// 持 ApiClient&(共享会话),每个方法组装 body → 发请求 → 连 IApiCall::finished 回调;
// 句柄自管理(完成后 deleteLater不手动 delete。
class ApiColorTemplateRepository : public IColorTemplateRepository {
public:
explicit ApiColorTemplateRepository(net::ApiClient& api);
void saveLvlTemplate(const QString& projectId, const QString& templateName,
const QJsonObject& properties,
std::function<void(bool ok, QString msg)> cb) override;
void listLvlTemplates(const QString& projectId,
std::function<void(bool ok, QJsonArray list, QString msg)> cb) override;
void newClrScheme(const QString& projectId, const QJsonObject& properties,
std::function<void(bool ok, QString msg)> cb) override;
void listClrSchemes(const QString& projectId,
std::function<void(bool ok, QJsonArray list, QString msg)> cb) override;
private:
net::ApiClient& api_;
};
} // namespace geopro::data

View File

@ -35,6 +35,14 @@ struct SectionData {
geopro::core::ColorScale scale; geopro::core::ColorScale scale;
}; };
// 二维足迹折线(测线/轨迹):一串 WGS84 经纬度点lat[i]/lon[i] 一一对应)。
// 渲染时经 Scene 共享 GeoLocalFrame 投影到世界 XY、Z=摆放高程,平铺进 3D 地图。
// 不含色阶/深度(足迹是纯几何线,区别于帘面的 Grid+色阶)。
struct MapLine {
std::vector<double> lat, lon;
bool valid() const { return lat.size() == lon.size() && lat.size() >= 2; }
};
// 三维场景仓储抽象异步spec §6 评审 HIGH // 三维场景仓储抽象异步spec §6 评审 HIGH
// 取数方法走回调 std::functionLocalSample 本地数据同步算好后直接回调; // 取数方法走回调 std::functionLocalSample 本地数据同步算好后直接回调;
// 将来 Api3dRepository 在网络完成时回调,上层不变)。 // 将来 Api3dRepository 在网络完成时回调,上层不变)。
@ -66,6 +74,12 @@ public:
virtual void loadSection(const std::string& dsId, virtual void loadSection(const std::string& dsId,
std::function<void(SectionData)> onOk, OnError onErr) = 0; std::function<void(SectionData)> onOk, OnError onErr) = 0;
// 异步:加载二维足迹折线(测线/轨迹的 WGS84 经纬序列)。
// Api 实现走 ERT 轨迹端点(dd/ert/trajectory/line)异步回调;本地样本给 mock 折线同步回调。
// 契约同上onOk/onErr 必须在主线程调用。
virtual void loadMapLine(const std::string& dsId,
std::function<void(MapLine)> onOk, OnError onErr) = 0;
// 异步:加载地形 DEM/影像路径。 // 异步:加载地形 DEM/影像路径。
virtual void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) = 0; virtual void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) = 0;

View File

@ -0,0 +1,37 @@
#pragma once
#include <functional>
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
namespace geopro::data {
// 色阶模板库仓储抽象lvl 等值线模板 + clr 连续色阶)。回调式异步:
// 仓储只做「组装请求 + 取数组/状态」的传输职责领域解析colorBar/颜色串)留在对话框。
// 回调在 Qt 主线程经 IApiCall::finished 触发;调用方须用 QPointer 守卫可能已关闭的对话框。
class IColorTemplateRepository {
public:
virtual ~IColorTemplateRepository() = default;
// 另存 lvl 模板POST /business/lvlTemplate。
virtual void saveLvlTemplate(const QString& projectId, const QString& templateName,
const QJsonObject& properties,
std::function<void(bool ok, QString msg)> cb) = 0;
// 列 lvl 模板POST /business/lvlTemplate/pagepageNo=1,pageSize=1000
// 回调 list = 响应 data.list 数组(每项含 templateName/properties
virtual void listLvlTemplates(const QString& projectId,
std::function<void(bool ok, QJsonArray list, QString msg)> cb) = 0;
// 新建 clr 色阶POST /business/clr/colorGradation。
virtual void newClrScheme(const QString& projectId, const QJsonObject& properties,
std::function<void(bool ok, QString msg)> cb) = 0;
// 列 clr 色阶GET .../queryCLRColorGradation/{projectId}。
// 注意:响应 data 顶层为数组,回调 list = 该数组(每项含 properties.name/colorscale
virtual void listClrSchemes(const QString& projectId,
std::function<void(bool ok, QJsonArray list, QString msg)> cb) = 0;
};
} // namespace geopro::data

View File

@ -49,8 +49,11 @@ DsDimension LocalSample3dRepository::dimensionOf(const DsRow& ds) const {
} }
// 切片:三维分析栏。 // 切片:三维分析栏。
if (c == "dd_slice") return DsDimension::Analysis3D; if (c == "dd_slice") return DsDimension::Analysis3D;
// 轨迹:二维数据集。 // 足迹型(测线/各类轨迹):二维数据集(与 Api3dRepository 同口径)。
if (c == "dd_trajectory_data") return DsDimension::Dim2D; if (c == "dd_trajectory_data" || c == "dd_transient_electromagnetic_trajectory_data" ||
c == "dd_radar_channel_trajectory" || c == "dd_radar_rtk_trajectory") {
return DsDimension::Dim2D;
}
return DsDimension::Other; return DsDimension::Other;
} }
@ -122,6 +125,25 @@ void LocalSample3dRepository::loadSection(const std::string& /*dsId*/,
} }
} }
void LocalSample3dRepository::loadMapLine(const std::string& /*dsId*/,
std::function<void(MapLine)> onOk, OnError onErr) {
// P1 样本:取样本 grid1 的 lat/lon 作为足迹折线(测试/离线用,不依赖网络)。
// 真实 Api 实现走 dd/ert/trajectory/line 端点按 dsId 取经纬。
try {
const core::Grid g = base_.loadGrid("grid1");
MapLine line;
line.lat = g.lat; // 样本 grid 的测线经纬(与帘面同源 → 同系配准)
line.lon = g.lon;
if (!line.valid()) {
onErr("LocalSample3dRepository::loadMapLine: 样本无有效经纬折线");
return;
}
onOk(std::move(line)); // 本地同步回调
} catch (const std::exception& e) {
onErr(std::string("LocalSample3dRepository::loadMapLine: ") + e.what());
}
}
void LocalSample3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)> onOk, void LocalSample3dRepository::loadTerrainPaths(std::function<void(TerrainPaths)> onOk,
OnError onErr) { OnError onErr) {
try { try {

View File

@ -30,6 +30,8 @@ public:
OnError onErr) override; OnError onErr) override;
void loadSection(const std::string& dsId, std::function<void(SectionData)> onOk, void loadSection(const std::string& dsId, std::function<void(SectionData)> onOk,
OnError onErr) override; OnError onErr) override;
void loadMapLine(const std::string& dsId, std::function<void(MapLine)> onOk,
OnError onErr) override;
void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) override; void loadTerrainPaths(std::function<void(TerrainPaths)> onOk, OnError onErr) override;
// 切片 CRUDspec §6.3 内存态 stub // 切片 CRUDspec §6.3 内存态 stub

View File

@ -11,6 +11,8 @@ vtkSmartPointer<vtkLookupTable> buildLut(const geopro::core::ColorScale& cs, dou
for (int t = 0; t < n; ++t) { for (int t = 0; t < n; ++t) {
const double val = vmin + (vmax - vmin) * t / (n - 1); const double val = vmin + (vmax - vmin) * t / (n - 1);
const auto c = cs.colorAt(val); const auto c = cs.colorAt(val);
// 复刻原版 three 渲染parseColor 只取 rgb、MeshBasicMaterial opacity=1
// 忽略 colorBar 的 alpha画满不透明 RGB。
lut->SetTableValue(t, c.r / 255.0, c.g / 255.0, c.b / 255.0, 1.0); lut->SetTableValue(t, c.r / 255.0, c.g / 255.0, c.b / 255.0, 1.0);
} }
lut->Build(); lut->Build();

View File

@ -1,16 +1,21 @@
#include "actors/CurtainActor.hpp" #include "actors/CurtainActor.hpp"
#include <vtkBandedPolyDataContourFilter.h> #include <vtkBandedPolyDataContourFilter.h>
#include <vtkCellData.h>
#include <vtkDataArray.h>
#include <vtkDataSetSurfaceFilter.h> #include <vtkDataSetSurfaceFilter.h>
#include <vtkDoubleArray.h> #include <vtkDoubleArray.h>
#include <vtkNew.h> #include <vtkNew.h>
#include <vtkPointData.h> #include <vtkPointData.h>
#include <vtkPoints.h> #include <vtkPoints.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h> #include <vtkPolyDataMapper.h>
#include <vtkStructuredGrid.h> #include <vtkStructuredGrid.h>
#include <vtkUnsignedCharArray.h>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <utility>
#include <vector> #include <vector>
#include <cstddef> #include <cstddef>
@ -19,10 +24,6 @@
namespace geopro::render { namespace geopro::render {
namespace {
// LUT 级数。
constexpr int kLutLevels = 256;
} // namespace
vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g, vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
const geopro::core::ColorScale& cs, const geopro::core::ColorScale& cs,
@ -110,8 +111,6 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
} }
} }
auto lut = buildLut(cs, vmin, vmax, kLutLevels);
// structuredGrid → 表面 polydata(消隐格已剔除) → banded contour(分段色带,色带#18)。 // structuredGrid → 表面 polydata(消隐格已剔除) → banded contour(分段色带,色带#18)。
vtkNew<vtkDataSetSurfaceFilter> surf; vtkNew<vtkDataSetSurfaceFilter> surf;
surf->SetInputData(sgrid); surf->SetInputData(sgrid);
@ -123,13 +122,40 @@ vtkSmartPointer<vtkActor> buildCurtain(const geopro::core::Grid& g,
} else { } else {
banded->GenerateValues(20, vmin, vmax); banded->GenerateValues(20, vmin, vmax);
} }
banded->SetScalarModeToValue(); banded->SetScalarModeToValue(); // 每个色带 cell 标量 = 该带等值线值colorBar 真实 stop
banded->Update();
// 逐色带精确上色,复刻原版 threeContour.js getTerrainColor
// 值 v∈[stops[i],stops[i+1]) 取「上界」stops[i+1] 的颜色(我们的 colorAt 取下界 stops[i]
// 会让整条色带整体偏浅一段);满不透明 RGB原版 parseColor 忽略 alpha、opacity=1
const std::vector<std::pair<double, geopro::core::Rgba>> csStops = cs.stops();
auto upperColor = [&csStops](double v) -> geopro::core::Rgba {
if (csStops.empty()) return geopro::core::Rgba{0, 0, 0, 255};
if (v <= csStops.front().first) return csStops.front().second;
if (v >= csStops.back().first) return csStops.back().second;
auto it = std::upper_bound(
csStops.begin(), csStops.end(), v,
[](double val, const std::pair<double, geopro::core::Rgba>& s) { return val < s.first; });
return it->second; // 第一个 value > v 的 stop = 上界
};
vtkNew<vtkPolyData> shaded;
shaded->DeepCopy(banded->GetOutput());
vtkNew<vtkUnsignedCharArray> bandColors;
bandColors->SetNumberOfComponents(3);
if (vtkDataArray* cellScalars = shaded->GetCellData()->GetScalars()) {
const vtkIdType nc = shaded->GetNumberOfCells();
bandColors->SetNumberOfTuples(nc);
for (vtkIdType ci = 0; ci < nc; ++ci) {
const auto c = upperColor(cellScalars->GetTuple1(ci));
bandColors->SetTuple3(ci, c.r, c.g, c.b);
}
shaded->GetCellData()->SetScalars(bandColors);
}
vtkNew<vtkPolyDataMapper> mapper; vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(banded->GetOutputPort()); mapper->SetInputData(shaded);
mapper->SetScalarModeToUseCellData(); mapper->SetScalarModeToUseCellData();
mapper->SetLookupTable(lut); mapper->SetColorModeToDirectScalars(); // cell 标量即 RGB不再过 LUT
mapper->SetScalarRange(vmin, vmax);
auto actor = vtkSmartPointer<vtkActor>::New(); auto actor = vtkSmartPointer<vtkActor>::New();
actor->SetMapper(mapper); actor->SetMapper(mapper);

View File

@ -47,4 +47,39 @@ vtkSmartPointer<vtkActor> buildSurveyLine(const geopro::core::Grid& g,
return actor; return actor;
} }
vtkSmartPointer<vtkActor> buildMapLine(const std::vector<double>& lat,
const std::vector<double>& lon, double worldZ,
const geopro::core::GeoLocalFrame& frame)
{
const std::size_t n = lat.size();
if (n < 2 || lon.size() != n) return vtkSmartPointer<vtkActor>::New();
vtkNew<vtkPoints> points;
for (std::size_t i = 0; i < n; ++i) {
auto p = frame.toLocal(lat[i], lon[i]);
points->InsertNextPoint(p.x, p.y, worldZ); // 平铺在指定世界 Z
}
vtkNew<vtkPolyLine> line;
line->GetPointIds()->SetNumberOfIds(static_cast<vtkIdType>(n));
for (std::size_t i = 0; i < n; ++i)
line->GetPointIds()->SetId(static_cast<vtkIdType>(i), static_cast<vtkIdType>(i));
vtkNew<vtkCellArray> cells;
cells->InsertNextCell(line);
vtkNew<vtkPolyData> poly;
poly->SetPoints(points);
poly->SetLines(cells);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(poly);
auto actor = vtkSmartPointer<vtkActor>::New();
actor->SetMapper(mapper);
actor->GetProperty()->SetColor(0.95, 0.55, 0.10); // 足迹:橙(与轨迹地图标记一致)
actor->GetProperty()->SetLineWidth(3.0);
return actor;
}
} // namespace geopro::render } // namespace geopro::render

View File

@ -1,4 +1,6 @@
#pragma once #pragma once
#include <vector>
#include <vtkActor.h> #include <vtkActor.h>
#include <vtkSmartPointer.h> #include <vtkSmartPointer.h>
@ -12,4 +14,11 @@ namespace geopro::render {
vtkSmartPointer<vtkActor> buildSurveyLine(const geopro::core::Grid& g, vtkSmartPointer<vtkActor> buildSurveyLine(const geopro::core::Grid& g,
const geopro::core::GeoLocalFrame& frame); const geopro::core::GeoLocalFrame& frame);
// 二维足迹折线(平铺进 3D 地图):把 lat/lon 序列经 frame 投影到世界 XYZ=worldZ。
// 与 buildSurveyLine 同口径(同 frame → 与帘面/底图同系配准),但 z 可控、不依赖 Grid。
// 点数 < 2 或 lat/lon 长度不一致 → 返回空 actor调用方自行判空
vtkSmartPointer<vtkActor> buildMapLine(const std::vector<double>& lat,
const std::vector<double>& lon, double worldZ,
const geopro::core::GeoLocalFrame& frame);
} // namespace geopro::render } // namespace geopro::render

View File

@ -124,6 +124,16 @@ target_sources(geopro_tests PRIVATE
) )
# hover inline qwt/ ScatterHoverTip # hover inline qwt/ ScatterHoverTip
target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp) target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp)
# normal/log/equalArea Qt/VTK
target_sources(geopro_tests PRIVATE
app/test_contour_levels.cpp
${CMAKE_SOURCE_DIR}/src/app/ContourLevels.cpp
)
# IO.lvl/.clr / Qt/VTK
target_sources(geopro_tests PRIVATE
app/test_color_scale_io.cpp
${CMAKE_SOURCE_DIR}/src/app/ColorScaleIO.cpp
)
# splitByDimension: ddCode -> // Qt/VTK # splitByDimension: ddCode -> // Qt/VTK
target_sources(geopro_tests PRIVATE target_sources(geopro_tests PRIVATE
app/test_dataset_dimension.cpp app/test_dataset_dimension.cpp

View File

@ -0,0 +1,74 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "ColorScaleIO.hpp"
using geopro::app::ClrData;
using geopro::app::generateClr;
using geopro::app::generateLvl;
using geopro::app::LvlRow;
using geopro::app::parseClr;
using geopro::app::parseLvl;
using geopro::core::Rgba;
// .lvl 往返:层级值/填充色/线型/线色 保真。
TEST(ColorScaleIO, LvlRoundTrip) {
std::vector<LvlRow> rows = {
{0.0, Rgba{0, 0, 170, 255}, false, Rgba{255, 0, 0, 255}},
{50.0, Rgba{0, 255, 0, 128}, true, Rgba{0, 0, 255, 255}},
{100.0, Rgba{48, 0, 48, 255}, false, Rgba{0, 0, 0, 255}},
};
const std::string text = generateLvl(rows);
const auto back = parseLvl(text);
ASSERT_EQ(back.size(), 3u);
EXPECT_DOUBLE_EQ(back[0].level, 0.0);
EXPECT_DOUBLE_EQ(back[2].level, 100.0);
EXPECT_EQ(back[0].color.b, 170); // 填充色 B
EXPECT_EQ(back[1].color.a, 128); // 填充色 alpha
EXPECT_TRUE(back[1].dashed); // 虚线
EXPECT_FALSE(back[0].dashed);
EXPECT_EQ(back[0].lineColor.r, 255); // 线色 R
}
// .lvl 头校验失败 → 空。
TEST(ColorScaleIO, LvlBadHeaderEmpty) {
EXPECT_TRUE(parseLvl("not a lvl file\nfoo\nbar").empty());
}
// .clr 往返pos/RGB/透明度保真,头部计数正确。
TEST(ColorScaleIO, ClrRoundTrip) {
ClrData clr;
clr.opacity = 0.5;
clr.stops = {{0.0, Rgba{0, 0, 255, 255}}, {0.5, Rgba{0, 255, 0, 255}},
{1.0, Rgba{255, 0, 0, 255}}};
const std::string text = generateClr(clr);
EXPECT_NE(text.find("ColorMap 5 0 6 2"), std::string::npos); // 3 色 + 2 透明度行
const ClrData back = parseClr(text);
ASSERT_EQ(back.stops.size(), 3u);
EXPECT_DOUBLE_EQ(back.stops[0].first, 0.0);
EXPECT_DOUBLE_EQ(back.stops[2].first, 1.0);
EXPECT_EQ(back.stops[2].second.r, 255);
EXPECT_NEAR(back.opacity, 0.5, 1e-9);
}
// .clr 头校验失败 → 空。
TEST(ColorScaleIO, ClrBadHeaderEmpty) {
EXPECT_TRUE(parseClr("Bogus 1 2 3\n0 0 0 0\n").stops.empty());
}
// 畸形/非数字内容不得崩溃外部文件不可信stod 须兜底):含合法头但数据行是垃圾。
TEST(ColorScaleIO, MalformedInputDoesNotThrow) {
EXPECT_NO_THROW({
auto a = parseLvl("LVL3\ncols\nfoo bar baz\nNaN qux R? Gx\n");
(void)a;
});
EXPECT_NO_THROW({
auto b = parseClr("ColorMap 4 0 6 2\nxx yy zz ww\nabc def ghi jkl\n0 0\n100 0\n");
(void)b;
});
// 合法头 + 垃圾色行 → 该行被跳过,不产出非法断点。
EXPECT_TRUE(parseClr("ColorMap 4 0 6 2\nfoo bar baz qux\n0.0 0\n100.0 0\n").stops.empty());
}

View File

@ -0,0 +1,84 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <vector>
#include "ContourLevels.hpp"
using geopro::app::ContourLevelParams;
using geopro::app::generateContourLevels;
namespace {
ContourLevelParams normal(double mn, double mx, double interval) {
ContourLevelParams p;
p.method = ContourLevelParams::Method::Normal;
p.minValue = mn;
p.maxValue = mx;
p.interval = interval;
return p;
}
} // namespace
// normallen=ceil((max-min)/间隔),等距升序,首=min末<max不含 max复刻原版
TEST(ContourLevels, NormalEvenSpacing) {
auto lv = generateContourLevels(normal(0.0, 10.0, 2.0), {});
ASSERT_EQ(lv.size(), 5u); // ceil(10/2)=5
EXPECT_DOUBLE_EQ(lv.front(), 0.0);
EXPECT_DOUBLE_EQ(lv[1], 2.0);
EXPECT_DOUBLE_EQ(lv.back(), 8.0); // 0,2,4,6,8不含 10
}
// normal不整除时向上取整层数。
TEST(ContourLevels, NormalCeilLayerCount) {
auto lv = generateContourLevels(normal(0.0, 10.0, 3.0), {});
EXPECT_EQ(lv.size(), 4u); // ceil(10/3)=4 → 0,3,6,9
EXPECT_DOUBLE_EQ(lv.back(), 9.0);
}
// normal 非法间隔 → 空。
TEST(ContourLevels, NormalInvalidIntervalEmpty) {
EXPECT_TRUE(generateContourLevels(normal(0.0, 10.0, 0.0), {}).empty());
EXPECT_TRUE(generateContourLevels(normal(10.0, 0.0, 2.0), {}).empty());
}
// logarithmic升序、末=max跨数量级细分。注原版会把 [A,1] 区间细分值(<min也压入
// 故 front 可能 < min此为复刻的真实行为仅校验升序 + 含 min + 末=max。
TEST(ContourLevels, LogarithmicSortedSpansRange) {
ContourLevelParams p;
p.method = ContourLevelParams::Method::Logarithmic;
p.minValue = 1.0;
p.maxValue = 1000.0;
p.logLinesCount = 4;
auto lv = generateContourLevels(p, {});
ASSERT_GE(lv.size(), 2u);
EXPECT_DOUBLE_EQ(lv.back(), 1000.0);
for (std::size_t i = 1; i < lv.size(); ++i) EXPECT_LE(lv[i - 1], lv[i]); // 升序
EXPECT_NE(std::find(lv.begin(), lv.end(), 1.0), lv.end()); // 含 min
}
// equalArea样本充足 → 分位(升序、个数=层数、覆盖到最大值)。
TEST(ContourLevels, EqualAreaQuantilesFromSamples) {
ContourLevelParams p;
p.method = ContourLevelParams::Method::EqualArea;
p.equalAreaLayerCount = 4;
std::vector<double> samples;
for (int i = 0; i < 100; ++i) samples.push_back(static_cast<double>(i)); // 0..99
auto lv = generateContourLevels(p, samples);
ASSERT_EQ(lv.size(), 4u); // 3 个分位 + 最大值
EXPECT_DOUBLE_EQ(lv.front(), 0.0); // 第 0 分位
EXPECT_DOUBLE_EQ(lv.back(), 99.0); // 含最大值
for (std::size_t i = 1; i < lv.size(); ++i) EXPECT_LE(lv[i - 1], lv[i]);
}
// equalArea样本不足 → 退化等距线性(复刻原版失败兜底)。
TEST(ContourLevels, EqualAreaFallbackLinearWhenSamplesScarce) {
ContourLevelParams p;
p.method = ContourLevelParams::Method::EqualArea;
p.equalAreaLayerCount = 5;
p.minValue = 0.0;
p.maxValue = 10.0;
auto lv = generateContourLevels(p, {1.0, 2.0}); // 仅 2 个样本 < 5
ASSERT_EQ(lv.size(), 5u);
EXPECT_DOUBLE_EQ(lv.front(), 0.0);
EXPECT_DOUBLE_EQ(lv[1], 2.0); // step=10/5=2
}

View File

@ -25,6 +25,7 @@ struct FakeView : I3dSceneView {
int surveyLines = 0; int surveyLines = 0;
int curtains = 0; int curtains = 0;
int volumes = 0; int volumes = 0;
int mapLines = 0;
int terrains = 0; int terrains = 0;
int renders = 0; int renders = 0;
bool lastIs2D = false; bool lastIs2D = false;
@ -43,26 +44,47 @@ struct FakeView : I3dSceneView {
// 按 ds 记录图元数,使 removeDataset 能按量回退(反映增量场景状态)。 // 按 ds 记录图元数,使 removeDataset 能按量回退(反映增量场景状态)。
std::map<std::string, std::pair<int, int>> perDs; // dsId → (curtains, volumes) std::map<std::string, std::pair<int, int>> perDs; // dsId → (curtains, volumes)
std::map<std::string, int> perDsMapLines; // dsId → mapLine 数removeDataset 回退用)
double lastMapLineZ = 0.0; // 最近一次 addMapLine 的 worldZ摆放验证
double refElev = 0.0; // 地表高程基准(顶/底摆放锚定)
// 色阶编辑:记录最近一次 addVolume 收到的色阶 + removeDataset 调用数(验证就地重渲染)。
core::ColorScale lastVolumeScale;
int removeCalls = 0;
// clear 模型化"移除所有数据图元"计数归零clears 累加。 // clear 模型化"移除所有数据图元"计数归零clears 累加。
void clear() override { void clear() override {
++clears; ++clears;
surveyLines = curtains = volumes = terrains = 0; surveyLines = curtains = volumes = mapLines = terrains = 0;
perDs.clear(); perDs.clear();
perDsMapLines.clear();
} }
void setVerticalExaggeration(double v) override { ve = v; } void setVerticalExaggeration(double v) override { ve = v; }
double zRefElev() const override { return refElev; }
void addSurveyLine(const core::Grid&) override { ++surveyLines; } void addSurveyLine(const core::Grid&) override { ++surveyLines; }
void addCurtain(const std::string& dsId, const core::Grid&, const core::ColorScale&) override { void addCurtain(const std::string& dsId, const core::Grid&, const core::ColorScale&) override {
++curtains; ++curtains;
++perDs[dsId].first; ++perDs[dsId].first;
} }
void addVolume(const std::string& dsId, const data::VolumeGrid&, void addVolume(const std::string& dsId, const data::VolumeGrid&,
const core::ColorScale&) override { const core::ColorScale& cs) override {
++volumes; ++volumes;
++perDs[dsId].second; ++perDs[dsId].second;
lastVolumeScale = cs;
}
void addMapLine(const std::string& dsId, const data::MapLine&, double worldZ) override {
++mapLines;
++perDsMapLines[dsId];
lastMapLineZ = worldZ;
} }
void addTerrain(const data::TerrainPaths&) override { ++terrains; } void addTerrain(const data::TerrainPaths&) override { ++terrains; }
void removeDataset(const std::string& dsId) override { void removeDataset(const std::string& dsId) override {
++removeCalls;
auto ml = perDsMapLines.find(dsId);
if (ml != perDsMapLines.end()) {
mapLines -= ml->second;
perDsMapLines.erase(ml);
}
auto it = perDs.find(dsId); auto it = perDs.find(dsId);
if (it == perDs.end()) return; if (it == perDs.end()) return;
curtains -= it->second.first; curtains -= it->second.first;
@ -142,6 +164,13 @@ struct FakeSceneRepo : data::I3dSceneRepository {
s.scale.addStop(1.0, core::Rgba{255, 0, 0, 255}); s.scale.addStop(1.0, core::Rgba{255, 0, 0, 255});
onOk(std::move(s)); // 同步回调(异步壳) onOk(std::move(s)); // 同步回调(异步壳)
} }
void loadMapLine(const std::string&, std::function<void(data::MapLine)> onOk,
OnError) override {
data::MapLine line;
line.lat = {22.0, 22.001, 22.002};
line.lon = {114.0, 114.001, 114.002};
onOk(std::move(line)); // 同步回调(异步壳)
}
void loadTerrainPaths(std::function<void(data::TerrainPaths)> onOk, OnError) override { void loadTerrainPaths(std::function<void(data::TerrainPaths)> onOk, OnError) override {
onOk(data::TerrainPaths{"dem.tif", "image.tif"}); onOk(data::TerrainPaths{"dem.tif", "image.tif"});
} }
@ -267,6 +296,50 @@ TEST(VtkSceneController, MultipleDatasetsAddMultipleCurtains) {
EXPECT_EQ(view.curtains, 3); EXPECT_EQ(view.curtains, 3);
} }
// ── 色阶编辑器「确定」setVolumeColorScale ──
// 已渲染三维体改色阶 → 移除旧体素 + 以新色阶重建(体素计数不变,但新色阶下发)。
TEST(VtkSceneController, SetVolumeColorScaleRebuildsCheckedVolume) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
sc.volumeIds = {"ds1"};
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"});
ASSERT_EQ(view.volumes, 1);
const int removesBefore = view.removeCalls;
core::ColorScale edited; // 三段(与初始两段区分)
edited.addStop(0.0, core::Rgba{0, 0, 0, 255});
edited.addStop(0.5, core::Rgba{128, 128, 128, 255});
edited.addStop(1.0, core::Rgba{255, 255, 255, 255});
c.setVolumeColorScale("ds1", edited);
EXPECT_EQ(view.volumes, 1); // 移除 1 + 新增 1 → 净计数不变
EXPECT_EQ(view.removeCalls, removesBefore + 1); // 旧体素被移除
EXPECT_EQ(view.lastVolumeScale.stopCount(), 3u); // 新色阶(三段)已下发
}
// 会话级 mock 持久:已加载的体编辑色阶后,取消再勾选仍用编辑后的色阶(命中缓存,不回退默认)。
TEST(VtkSceneController, SetVolumeColorScalePersistsAcrossRecheck) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
sc.volumeIds = {"ds1"};
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"}); // 加载体(填充 volumeCache_
ASSERT_EQ(view.lastVolumeScale.stopCount(), 2u); // 初始两段
core::ColorScale edited; // 编辑成三段
edited.addStop(0.0, core::Rgba{0, 0, 0, 255});
edited.addStop(0.5, core::Rgba{128, 128, 128, 255});
edited.addStop(1.0, core::Rgba{255, 255, 255, 255});
c.setVolumeColorScale("ds1", edited);
ASSERT_EQ(view.lastVolumeScale.stopCount(), 3u);
c.setCheckedDatasets({}); // 取消勾选
c.setCheckedDatasets({"ds1"}); // 再勾选 → 命中缓存(含编辑后色阶)
EXPECT_EQ(view.lastVolumeScale.stopCount(), 3u);
}
// ── P2坐标轴 / 快捷视图 / Zoom 编排 ── // ── P2坐标轴 / 快捷视图 / Zoom 编排 ──
// 每次重建都把当前坐标轴设置下发给视图clear 后须重设)。 // 每次重建都把当前坐标轴设置下发给视图clear 后须重设)。
@ -330,3 +403,100 @@ TEST(VtkSceneController, ZoomAndFitForwarded) {
c.fit(); c.fit();
EXPECT_EQ(view.fitCalls, 1); EXPECT_EQ(view.fitCalls, 1);
} }
// ── 二维数据集视图:足迹平铺进 View3D ──
// 默认摆放模式=关闭(0) → 勾选 2D 足迹不渲染(仅记录勾选)。
TEST(VtkSceneController, TwoDPlacementOffDoesNotRender) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setChecked2DDatasets({"traj1"}); // 摆放默认关闭
EXPECT_EQ(view.mapLines, 0);
}
// 摆放 Z=0(1) + 勾选足迹 → 1 条 mapLineworldZ=0不影响帘面/体素计数。
TEST(VtkSceneController, TwoDPlacementZeroAddsMapLine) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.set2DPlacement(1, 0.0); // Z=0
c.setChecked2DDatasets({"traj1"});
EXPECT_EQ(view.mapLines, 1);
EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0);
EXPECT_EQ(view.curtains, 0);
EXPECT_EQ(view.volumes, 0);
}
// 顶部/底部摆放锚定真实地表高程worldZ = zRefElev ± 偏移(而非世界 0 ± 偏移)。
TEST(VtkSceneController, TwoDPlacementTopBottomAnchorToSurfaceElev) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
view.refElev = 1200.0; // 地表高程基准
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.set2DPlacement(2, 0.0); // 顶部
c.setChecked2DDatasets({"traj1"});
EXPECT_DOUBLE_EQ(view.lastMapLineZ, 1200.0 + 50.0); // 贴地表上方
c.set2DPlacement(3, 0.0); // 底部
EXPECT_DOUBLE_EQ(view.lastMapLineZ, 1200.0 - 50.0); // 地表下方
}
// 取消勾选 2D 足迹 → 增量移除该足迹图元(不整场 clear
TEST(VtkSceneController, TwoDUncheckRemovesMapLine) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.set2DPlacement(1, 0.0);
c.setChecked2DDatasets({"traj1"});
ASSERT_EQ(view.mapLines, 1);
const int clearsBefore = view.clears;
c.setChecked2DDatasets({}); // 取消勾选
EXPECT_EQ(view.mapLines, 0);
EXPECT_EQ(view.clears, clearsBefore); // 增量移除,不整场 clear
}
// 2D 足迹与 3D 帘面共存且独立:勾选剖面 + 足迹,各出各的图元,互不影响。
TEST(VtkSceneController, TwoDFootprintCoexistsWith3DCurtain) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"prof1"}); // 3D 帘面
c.set2DPlacement(1, 0.0);
c.setChecked2DDatasets({"traj1"}); // 2D 足迹
EXPECT_EQ(view.curtains, 1);
EXPECT_EQ(view.mapLines, 1);
c.setChecked2DDatasets({}); // 取消足迹 → 帘面不受影响
EXPECT_EQ(view.mapLines, 0);
EXPECT_EQ(view.curtains, 1);
}
// 自定义摆放(4) → worldZ=customZ改摆放重摆已勾选足迹移除旧 + 按新 Z 重加)。
TEST(VtkSceneController, TwoDPlacementCustomZAndReplace) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.set2DPlacement(4, 123.5); // 自定义 Z
c.setChecked2DDatasets({"traj1"});
ASSERT_EQ(view.mapLines, 1);
EXPECT_DOUBLE_EQ(view.lastMapLineZ, 123.5);
const int removesBefore = view.removeCalls;
c.set2DPlacement(4, 200.0); // 改自定义 Z → 重摆
EXPECT_EQ(view.mapLines, 1); // 移除 1 + 新增 1 → 净计数不变
EXPECT_EQ(view.removeCalls, removesBefore + 1); // 旧足迹被移除
EXPECT_DOUBLE_EQ(view.lastMapLineZ, 200.0); // 新 Z 已下发
}
// 摆放从关闭(0)切到 Z=0(1) → 已勾选但未渲染的足迹补画。
TEST(VtkSceneController, TwoDPlacementOffToOnDrawsCheckedFootprint) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setChecked2DDatasets({"traj1"}); // 关闭态:仅记录
ASSERT_EQ(view.mapLines, 0);
c.set2DPlacement(1, 0.0); // 切到 Z=0 → 补画
EXPECT_EQ(view.mapLines, 1);
}

View File

@ -37,9 +37,31 @@ TEST(LocalSample3dRepo, DimensionOfMapsDdCode) {
EXPECT_EQ(repo.dimensionOf(rowWith("dd_inversion_data")), DsDimension::Dim3D); EXPECT_EQ(repo.dimensionOf(rowWith("dd_inversion_data")), DsDimension::Dim3D);
EXPECT_EQ(repo.dimensionOf(rowWith("dd_slice")), DsDimension::Analysis3D); EXPECT_EQ(repo.dimensionOf(rowWith("dd_slice")), DsDimension::Analysis3D);
EXPECT_EQ(repo.dimensionOf(rowWith("dd_trajectory_data")), DsDimension::Dim2D); EXPECT_EQ(repo.dimensionOf(rowWith("dd_trajectory_data")), DsDimension::Dim2D);
// 足迹型(各类轨迹) → 二维spec §4.1)。
EXPECT_EQ(repo.dimensionOf(rowWith("dd_transient_electromagnetic_trajectory_data")),
DsDimension::Dim2D);
EXPECT_EQ(repo.dimensionOf(rowWith("dd_radar_channel_trajectory")), DsDimension::Dim2D);
EXPECT_EQ(repo.dimensionOf(rowWith("dd_radar_rtk_trajectory")), DsDimension::Dim2D);
EXPECT_EQ(repo.dimensionOf(rowWith("dd_unknown_xyz")), DsDimension::Other); EXPECT_EQ(repo.dimensionOf(rowWith("dd_unknown_xyz")), DsDimension::Other);
} }
// loadMapLine本地样本取 grid1 经纬作足迹折线lat/lon 等长、>=2 点、valid
TEST(LocalSample3dRepo, LoadMapLineCallsBackWithValidLine) {
LocalSampleRepository base(kDir);
LocalSample3dRepository repo(base, kCrs, 22.0, 114.0);
bool ok = false;
std::string err;
MapLine got;
repo.loadMapLine("traj1", [&](MapLine l) { ok = true; got = std::move(l); },
[&](const std::string& m) { err = m; });
ASSERT_TRUE(ok) << "loadMapLine onErr: " << err;
EXPECT_EQ(got.lat.size(), got.lon.size());
EXPECT_GE(got.lat.size(), 2u);
EXPECT_TRUE(got.valid());
}
// loadVolume回调收到有效 VolumeGridnx>0 且 vmax>vmin需 PROJ_DATA。 // loadVolume回调收到有效 VolumeGridnx>0 且 vmax>vmin需 PROJ_DATA。
TEST(LocalSample3dRepo, LoadVolumeCallsBackWithValidGrid) { TEST(LocalSample3dRepo, LoadVolumeCallsBackWithValidGrid) {
LocalSampleRepository base(kDir); LocalSampleRepository base(kDir);
@ -113,6 +135,18 @@ TEST(Api3dRepo, VolumeInfoBeforeLoad) {
EXPECT_EQ(info.params.colorScaleId, "src-a"); EXPECT_EQ(info.params.colorScaleId, "src-a");
} }
// loadMapLineApiloadAsync 返回空句柄 → onErr不崩给明确错误
TEST(Api3dRepo, LoadMapLineNullHandleCallsOnError) {
StubAsyncRepo dsRepo;
auto frame = std::make_shared<geopro::core::GeoLocalFrame>(22.0, 114.0);
Api3dRepository repo(dsRepo, frame);
bool errCalled = false;
repo.loadMapLine("traj1", [](MapLine) { FAIL() << "不应成功(空句柄)"; },
[&](const std::string&) { errCalled = true; });
EXPECT_TRUE(errCalled);
}
// volumeInfo未知 dsId非三维体→ 返回 false不弹空对话框。 // volumeInfo未知 dsId非三维体→ 返回 false不弹空对话框。
TEST(Api3dRepo, VolumeInfoUnknownIdReturnsFalse) { TEST(Api3dRepo, VolumeInfoUnknownIdReturnsFalse) {
StubAsyncRepo dsRepo; StubAsyncRepo dsRepo;