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:
parent
b3b030767d
commit
5e60446210
|
|
@ -394,6 +394,93 @@
|
||||||
|
|
||||||
## 7. 表单、表格、对话框等通用组件(补充 · 原型未全部出现但客户端必备)
|
## 7. 表单、表格、对话框等通用组件(补充 · 原型未全部出现但客户端必备)
|
||||||
|
|
||||||
|
### 7.0 表单布局(编辑态 / 只读态)— 总则
|
||||||
|
|
||||||
|
> 本节是「编辑/只读表单」的**单一事实来源**,统一约束四类表单:①弹出编辑/新建对话框、②属性子视图(对象属性 / 数据集属性)、③动态表单(`getDynamicForm` 驱动)、④首选项设置。控件本身的样式仍引用 §7.1–7.3、§6.12–6.13;本节只定义「把这些控件组织成一张表单」的布局、分组、状态、校验与配色规则。
|
||||||
|
>
|
||||||
|
> **设计取向**(参考主流优秀客户端):macOS 系统设置 / Windows 11 设置(分组卡片 + 左标签)、JetBrains IDE 设置(密集左标签)、Figma 右侧属性面板(紧凑内联编辑)、Linear / Stripe(清晰节奏与即时校验)。在「信息密度优先」(§0.3)前提下取其**密集左标签 + 清晰分组 + 即时校验 + 克制留白**。
|
||||||
|
|
||||||
|
#### 7.0.1 两种表单形态(先定形态,再排布局)
|
||||||
|
|
||||||
|
| 形态 | 用途 | 实现 |
|
||||||
|
|---|---|---|
|
||||||
|
| **只读表单**(展示) | 纯查看的属性详情 | 用 §6.4 **属性键值表**(键值两列,数值等宽,不可编辑) |
|
||||||
|
| **可编辑表单**(编辑/新建) | 编辑、新建、设置 | 用本节「标签 + 控件」行式表单 |
|
||||||
|
|
||||||
|
**铁律**:一张表单只要存在**任一可编辑字段**,整张表即用「可编辑表单」形态;其中的只读字段以**禁用态控件**(§7.1 禁用)呈现,**不得**在同一张表单里一半键值表、一半输入框——保证可编辑与只读字段在同一栅格中对齐一致。
|
||||||
|
|
||||||
|
#### 7.0.2 表单栅格与行结构
|
||||||
|
|
||||||
|
| 元素 | 规范 |
|
||||||
|
|---|---|
|
||||||
|
| 标签位置 | **左侧标签列**(默认,密集专业风),标签文字**右对齐**贴近字段;字段名过长或窄单列对话框可改顶部标签 |
|
||||||
|
| 标签列宽 | 默认 `96–120px`,同一表单内**等宽对齐**(纯只读键值表用 §6.4 的 `72px`) |
|
||||||
|
| 标签 ↔ 字段间距 | `space/md`(12) |
|
||||||
|
| 字段控件高 | `28px`(§7.1);行与行垂直间距 `space/sm`(8) |
|
||||||
|
| 字段宽度 | 宽面板/对话框中**不要拉满**,单字段最大宽约 `360px`(多行/长文本可更宽);窄属性面板中填充可用宽度 |
|
||||||
|
| 表单内边距 | 对话框内 `space/xl`(24);面板内 `space/lg`(12–16) |
|
||||||
|
| 列数 | **单列优先**;信息多用分组而非多列。仅「短字段(数值+单位)」可两列并排 |
|
||||||
|
|
||||||
|
#### 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。
|
||||||
|
- **脏标记**:表单无用户修改时「保存」按钮**置灰不可点**;任一字段改动后启用(与已落地的对象属性面板一致)。
|
||||||
|
- **提交中**:主按钮 loading(spinner/禁用)防重复提交;成功后 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)
|
||||||
|
|
||||||
| 状态 | 规范 |
|
| 状态 | 规范 |
|
||||||
|
|
|
||||||
|
|
@ -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` 现占位("色阶开发中")。做成色阶编辑器,影响 体/切片/帘面 渲染观感。 |
|
| **真实色阶编辑(P1–P4 ✅ 全期完成,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)落实。 |
|
||||||
|
|
|
||||||
|
|
@ -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) + 整体透明度(0–1 滑块) + `.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`(0–255);颜色选择用 `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);不做线形/标注、连续画布、模板 IO(P2–P4)。
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
// ── 顶部两列 grid(grid-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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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/projectId:lvl 模板库仓储句柄(可空 → 另存为/打开 禁用)。
|
||||||
|
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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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=实线 solid,true=虚线 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
|
||||||
|
|
@ -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'/>"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ enum class Glyph {
|
||||||
Fullscreen, // 全屏 / 最大化
|
Fullscreen, // 全屏 / 最大化
|
||||||
ChevronLeft, // 折叠抽屉(向左)
|
ChevronLeft, // 折叠抽屉(向左)
|
||||||
ChevronRight, // 展开抽屉(向右)
|
ChevronRight, // 展开抽屉(向右)
|
||||||
|
// 对象树类型图标(§6.1:GS 工区 / TM 测线)
|
||||||
|
WorkArea, // GS 检测对象(工区,地图定位标记)
|
||||||
|
SurveyLine, // TM 方法对象(测线,折线)
|
||||||
// 顶部应用栏图标
|
// 顶部应用栏图标
|
||||||
Workspace, // 工作空间(2x2 宫格)
|
Workspace, // 工作空间(2x2 宫格)
|
||||||
Folder, // 项目(文件夹)
|
Folder, // 项目(文件夹)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.10):text/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);
|
||||||
|
|
||||||
// 右:分页。
|
// 右:分页。
|
||||||
|
|
|
||||||
|
|
@ -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/focus、radius/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/dialog):Qt 顶层原生窗口忽略
|
||||||
|
QSS 的 border-radius,且 QSS 无法绘制窗口外阴影——此处刻意不做,留待原生/QGraphicsEffect。 */
|
||||||
|
|
||||||
/* ── ADS 停靠:标题栏做成「固定面板表头」(对齐原型)──────────────
|
/* ── ADS 停靠:标题栏做成「固定面板表头」(对齐原型)──────────────
|
||||||
面板已锁定(无关闭/浮动/拖动),每个区只一个标签即表头:统一浅色头 +
|
面板已锁定(无关闭/浮动/拖动),每个区只一个标签即表头:统一浅色头 +
|
||||||
蓝色下划线强调 + 深色加粗标题。各区一致,读起来像固定子窗口表头。 */
|
蓝色下划线强调 + 深色加粗标题。各区一致,读起来像固定子窗口表头。 */
|
||||||
|
|
|
||||||
|
|
@ -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:默认 96–120px,同表单内等宽对齐右标签)。
|
||||||
|
// 唯一事实来源——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);
|
||||||
|
|
|
||||||
|
|
@ -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.7:3–4s)──
|
||||||
|
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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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-indicator),InstantPopup 挂菜单。
|
||||||
|
// 文字取菜单标题(视图/项目管理/…),样式见 #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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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_);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
111
src/app/main.cpp
111
src/app/main.cpp
|
|
@ -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 面板(QGraphicsView,VTK 仅算几何)──
|
// ── 下方「数据详情」dock:平面图表多 Tab 面板(QGraphicsView,VTK 仅算几何)──
|
||||||
// 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。
|
// 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。
|
||||||
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 渲染可编辑表单;
|
|
||||||
// 确定→校验+提交(PUT,body 为推断结构,确切性以服务端为准)→成功刷新结构。
|
|
||||||
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("启动失败"),
|
||||||
|
|
|
||||||
|
|
@ -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 橙黄≈20–70 蓝≈190–260)。
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}};"));
|
||||||
|
|
|
||||||
|
|
@ -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,随其尺寸覆盖图区)。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.4):text/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
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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; // 节点对象 id(GS/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 类型图标 14px,text/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 节点上不显示「新建检测对象」——xlsx:tm 上新建GS 无效。)
|
// GS 节点:新建检测对象 / 新建方法对象。(TM 节点上不显示「新建检测对象」——xlsx:tm 上新建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 节点:不提供任何「新建」(测线下不能新增对象)——仅「导入数据集」。
|
||||||
// (xlsx:tm 上新建GS 无效,故不显示「新建检测对象」。)
|
|
||||||
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
|
|
||||||
add(QStringLiteral("导入数据集…"), QStringLiteral("importDs"));
|
add(QStringLiteral("导入数据集…"), QStringLiteral("importDs"));
|
||||||
}
|
}
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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/projectIdGetter:FilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; // heap,setGridData 重建
|
ColorMapService* colorSvc_ = nullptr; // heap,setGridData 重建
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
// 3D:DEM 地形 + 影像纹理。
|
// 3D:DEM 地形 + 影像纹理。
|
||||||
virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0;
|
virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0;
|
||||||
// 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。
|
// 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
// 二维足迹摆放高度(mode:0关闭 /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;
|
||||||
|
// 当前摆放模式下足迹的世界 Z(mode 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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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::function(LocalSample 本地数据同步算好后直接回调;
|
// 取数方法走回调 std::function(LocalSample 本地数据同步算好后直接回调;
|
||||||
// 将来 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/page(pageNo=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
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
// 切片 CRUD(spec §6.3 内存态 stub)
|
// 切片 CRUD(spec §6.3 内存态 stub)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 投影到世界 XY,Z=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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
// normal:len=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
|
||||||
|
}
|
||||||
|
|
@ -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 条 mapLine,worldZ=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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:回调收到有效 VolumeGrid(nx>0 且 vmax>vmin),需 PROJ_DATA。
|
// loadVolume:回调收到有效 VolumeGrid(nx>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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadMapLine(Api):loadAsync 返回空句柄 → 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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue