From 5e6044621013685af20986a67547562f9742aff1 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Mon, 22 Jun 2026 12:48:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(vtk):=20=E8=89=B2=E9=98=B6=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8(2D/3D=E5=85=B1=E4=BA=AB)+=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=BA=93=E5=90=8E=E7=AB=AF+=E5=89=96=E9=9D=A2=E7=9D=80?= =?UTF-8?q?=E8=89=B2=E4=BF=AE=E6=AD=A3+=E4=BA=8C=E7=BB=B4=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=9B=86=E8=B6=B3=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本会话主要交付: - 色阶配置对话框 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。 --- docs/Geopro3.0_视觉设计规范.md | 87 +++ docs/superpowers/HANDOFF-vtk-3d.md | 4 +- ...-06-19-vtk-3d-color-scale-editor-design.md | 111 ++++ .../2026-06-22-2d-dataset-vtk-view-design.md | 134 +++++ src/app/CMakeLists.txt | 8 + src/app/ColorGradientDialog.cpp | 435 ++++++++++++++ src/app/ColorGradientDialog.hpp | 82 +++ src/app/ColorScaleConfigDialog.cpp | 539 ++++++++++++++++++ src/app/ColorScaleConfigDialog.hpp | 86 +++ src/app/ColorScaleIO.cpp | 151 +++++ src/app/ColorScaleIO.hpp | 34 ++ src/app/ContourLevelDialog.cpp | 254 +++++++++ src/app/ContourLevelDialog.hpp | 58 ++ src/app/ContourLevels.cpp | 82 +++ src/app/ContourLevels.hpp | 26 + src/app/ContourLineDialog.cpp | 102 ++++ src/app/ContourLineDialog.hpp | 42 ++ src/app/Glyphs.cpp | 8 + src/app/Glyphs.hpp | 3 + src/app/GradientEditWidget.cpp | 333 +++++++++++ src/app/GradientEditWidget.hpp | 87 +++ src/app/ObjectFormDialog.cpp | 27 +- src/app/PanelHeader.cpp | 26 +- src/app/SettingsDialog.cpp | 104 +++- src/app/Theme.cpp | 63 +- src/app/Theme.hpp | 45 +- src/app/ToastOverlay.cpp | 107 ++++ src/app/ToastOverlay.hpp | 42 ++ src/app/TopBar.cpp | 112 ++-- src/app/TopBar.hpp | 5 +- src/app/VtkSceneView.cpp | 11 + src/app/VtkSceneView.hpp | 3 + src/app/main.cpp | 111 ++-- src/app/panels/AnomalyListPanel.cpp | 92 ++- src/app/panels/AnomalyListPanel.hpp | 8 + src/app/panels/DatasetAttrPanel.cpp | 2 +- src/app/panels/DatasetDetailPage.cpp | 12 +- src/app/panels/DatasetDetailPage.hpp | 14 + src/app/panels/DatasetDetailPanel.cpp | 11 + src/app/panels/DatasetDetailPanel.hpp | 13 + src/app/panels/DynamicFormEditor.cpp | 126 +++- src/app/panels/DynamicFormEditor.hpp | 13 +- src/app/panels/DynamicFormView.cpp | 50 +- src/app/panels/ObjectAttrPanel.cpp | 48 +- src/app/panels/ObjectTreePanel.cpp | 85 ++- src/app/panels/chart/ContourPlotItem.cpp | 9 +- src/app/panels/chart/ContourPlotItem.hpp | 7 + src/app/panels/chart/DetailViewFactory.cpp | 13 +- src/app/panels/chart/DetailViewFactory.hpp | 14 +- src/app/panels/chart/GridDataChartView.cpp | 44 +- src/app/panels/chart/GridDataChartView.hpp | 20 + src/controller/I3dSceneView.hpp | 5 + src/controller/VtkSceneController.cpp | 105 ++++ src/controller/VtkSceneController.hpp | 18 + src/data/CMakeLists.txt | 1 + src/data/api/Api3dRepository.cpp | 34 +- src/data/api/Api3dRepository.hpp | 2 + src/data/api/ApiColorTemplateRepository.cpp | 97 ++++ src/data/api/ApiColorTemplateRepository.hpp | 29 + src/data/repo/I3dSceneRepository.hpp | 14 + src/data/repo/IColorTemplateRepository.hpp | 37 ++ src/data/repo/LocalSample3dRepository.cpp | 26 +- src/data/repo/LocalSample3dRepository.hpp | 2 + src/render/ColorLutBuilder.cpp | 2 + src/render/actors/CurtainActor.cpp | 46 +- src/render/actors/MapLineActor.cpp | 35 ++ src/render/actors/MapLineActor.hpp | 9 + tests/CMakeLists.txt | 10 + tests/app/test_color_scale_io.cpp | 74 +++ tests/app/test_contour_levels.cpp | 84 +++ .../controller/test_vtk_scene_controller.cpp | 174 +++++- tests/data/test_3d_repo.cpp | 34 ++ 72 files changed, 4518 insertions(+), 223 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-19-vtk-3d-color-scale-editor-design.md create mode 100644 docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md create mode 100644 src/app/ColorGradientDialog.cpp create mode 100644 src/app/ColorGradientDialog.hpp create mode 100644 src/app/ColorScaleConfigDialog.cpp create mode 100644 src/app/ColorScaleConfigDialog.hpp create mode 100644 src/app/ColorScaleIO.cpp create mode 100644 src/app/ColorScaleIO.hpp create mode 100644 src/app/ContourLevelDialog.cpp create mode 100644 src/app/ContourLevelDialog.hpp create mode 100644 src/app/ContourLevels.cpp create mode 100644 src/app/ContourLevels.hpp create mode 100644 src/app/ContourLineDialog.cpp create mode 100644 src/app/ContourLineDialog.hpp create mode 100644 src/app/GradientEditWidget.cpp create mode 100644 src/app/GradientEditWidget.hpp create mode 100644 src/app/ToastOverlay.cpp create mode 100644 src/app/ToastOverlay.hpp create mode 100644 src/data/api/ApiColorTemplateRepository.cpp create mode 100644 src/data/api/ApiColorTemplateRepository.hpp create mode 100644 src/data/repo/IColorTemplateRepository.hpp create mode 100644 tests/app/test_color_scale_io.cpp create mode 100644 tests/app/test_contour_levels.cpp diff --git a/docs/Geopro3.0_视觉设计规范.md b/docs/Geopro3.0_视觉设计规范.md index 278678c..52cf520 100644 --- a/docs/Geopro3.0_视觉设计规范.md +++ b/docs/Geopro3.0_视觉设计规范.md @@ -394,6 +394,93 @@ ## 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) | 状态 | 规范 | diff --git a/docs/superpowers/HANDOFF-vtk-3d.md b/docs/superpowers/HANDOFF-vtk-3d.md index ecd9dca..3719cc0 100644 --- a/docs/superpowers/HANDOFF-vtk-3d.md +++ b/docs/superpowers/HANDOFF-vtk-3d.md @@ -70,7 +70,7 @@ - **4c-2 ✅ 已提交(44d31a8)**:列表选中异常→VTK 高亮联动(R84,list→VTK);`setSelectedAnomaly`(选中 actor 加粗线宽/点尺寸,其余恢复);anomalyProps_ 改 vtkActor。**反向(VTK点异常→回选列表)未做**(需异常 actor 拾取)。 - **4c-3 ✅ 已提交(c83f63a)**:异常属性对话框(R83,双击异常列表项弹只读:名称/类型/标记类型/归属三维体/异常体/顶点世界坐标/备注);`AnomalyPropertiesDialog`。**截图字段:模型/端点均无,不展示**(保存对话框截图为 mock 未持久化)。 - **#4 异常功能收口** ✅(4a→4c-3 全做完,编译绿+用户实测通过)。**剩余已知限制**:① 反向(VTK 点异常→回选列表)未做(需异常 actor 拾取);② 单条显隐状态跨 refresh 不持久;③ 全链 mock(三维体/切片端点未就绪),端点就绪后切真实。 -5. 分析栏右键接线:**已完成**(切片 保存/保存为/导出▸/删除 全接;体 切片▸/详情);`colorScaleRequested` 仍占位("色阶开发中")。已移除"显示/隐藏"(勾选即显隐)。 +5. 分析栏右键接线:**已完成**(切片 保存/保存为/导出▸/删除 全接;体 切片▸/详情/色阶);`colorScaleRequested` **已接 P1 色阶编辑器**(详见下表)。已移除"显示/隐藏"(勾选即显隐)。 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`。**已知限制**:切片采样分辨率/值域需渲染层回写仓储才有,当前不展示。 | -| **真实色阶编辑** | 功能 | 否 | `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` 推送。注意勿纳未跟踪文件。 | | **三级树 对象→三维体→切片** | 结构打磨 | 否 | `Column3DAnalysis` 体目前是顶层,缺"对象"根层(R 结构)。 | | **坐标轴 O点/字体弹框** | 打磨 | 否 | main.cpp 内 stub(TODO P4)落实。 | diff --git a/docs/superpowers/specs/2026-06-19-vtk-3d-color-scale-editor-design.md b/docs/superpowers/specs/2026-06-19-vtk-3d-color-scale-editor-design.md new file mode 100644 index 0000000..6a20a12 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-vtk-3d-color-scale-editor-design.md @@ -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)。 diff --git a/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md b/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md new file mode 100644 index 0000000..22926e1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md @@ -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 `): + - 项目 `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)。 + + diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 9f76d0e..6faa7eb 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -22,6 +22,7 @@ add_executable(geopro_desktop WIN32 main.cpp Theme.cpp TopBar.cpp + ToastOverlay.cpp Glyphs.cpp PanelHeader.cpp Credential.cpp @@ -67,6 +68,13 @@ add_executable(geopro_desktop WIN32 ExportDatasetDialog.cpp AnomalySaveDialog.cpp AnomalyPropertiesDialog.cpp + ColorGradientDialog.cpp + ColorScaleConfigDialog.cpp + ColorScaleIO.cpp + ContourLevelDialog.cpp + ContourLevels.cpp + ContourLineDialog.cpp + GradientEditWidget.cpp SettingsDialog.cpp SliceExport.cpp SlicePropertiesDialog.cpp diff --git a/src/app/ColorGradientDialog.cpp b/src/app/ColorGradientDialog.cpp new file mode 100644 index 0000000..57b6cfa --- /dev/null +++ b/src/app/ColorGradientDialog.cpp @@ -0,0 +1,435 @@ +#include "ColorGradientDialog.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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(r), static_cast(g), + static_cast(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(c.red()), static_cast(c.green()), + static_cast(c.blue()), static_cast(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& 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& init, double minValue, + double maxValue, double originMin, double originMax, + std::vector 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(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::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::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { onMinMaxChanged(); }); + connect(maxSpin_, QOverload::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 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(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 st; + for (const auto& [pos, c] : clr.stops) st.push_back({pos, c}); + gradient_->setStops(st); + onSelectionCleared(); + opacity_ = clr.opacity; + opacitySlider_->setValue(static_cast(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(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 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 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 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 diff --git a/src/app/ColorGradientDialog.hpp b/src/app/ColorGradientDialog.hpp new file mode 100644 index 0000000..215fa7c --- /dev/null +++ b/src/app/ColorGradientDialog.hpp @@ -0,0 +1,82 @@ +#pragma once +#include + +#include +#include + +#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& init, + double minValue, double maxValue, // 当前色阶范围(可编辑) + double originMin, double originMax, // 数据原始范围(数值范围只读+直方图域) + std::vector samples, // 直方图样本 + double opacity, + geopro::data::IColorTemplateRepository* tplRepo = nullptr, + QString projectId = {}, + QWidget* parent = nullptr); + + std::vector stops() const; // accept() 后有效,升序 + double opacity() const { return opacity_; } + +private: + struct Scheme { + QString name; + std::vector 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 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 diff --git a/src/app/ColorScaleConfigDialog.cpp b/src/app/ColorScaleConfigDialog.cpp new file mode 100644 index 0000000..0b29a3c --- /dev/null +++ b/src/app/ColorScaleConfigDialog.cpp @@ -0,0 +1,539 @@ +#include "ColorScaleConfigDialog.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#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(c.red()), + static_cast(c.green()), + static_cast(c.blue()), + static_cast(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(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(r), static_cast(g), + static_cast(b), + static_cast(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 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(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(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(rows_.size())) return -1; + return r; // 升序显示,行号即模型下标 +} + +void ColorScaleConfigDialog::onCellDoubleClicked(int row, int col) { + if (row < 0 || row >= static_cast(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(rows_.size()); ++i) { + const Row& ri = rows_[static_cast(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(idx)]; + double newLevel = sel.value; + if (idx > 0) // 升序:上一行(idx-1)为更低值,取两者中点 + newLevel = (rows_[static_cast(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(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(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 levels = generateContourLevels(p, samples_); + std::vector 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 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(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 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 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(text.size())) < 0) + QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("写入失败。")); +} + +void ColorScaleConfigDialog::loadColorBar( + const std::vector>& 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 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 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> 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 diff --git a/src/app/ColorScaleConfigDialog.hpp b/src/app/ColorScaleConfigDialog.hpp new file mode 100644 index 0000000..d170fe6 --- /dev/null +++ b/src/app/ColorScaleConfigDialog.hpp @@ -0,0 +1,86 @@ +#pragma once +#include + +#include +#include + +#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 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>& bar); + + QTableWidget* table_ = nullptr; + std::vector rows_; // 始终按 value 升序维护 + double vmin_ = 0.0; + double vmax_ = 0.0; + std::vector 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 diff --git a/src/app/ColorScaleIO.cpp b/src/app/ColorScaleIO.cpp new file mode 100644 index 0000000..368e4f6 --- /dev/null +++ b/src/app/ColorScaleIO.cpp @@ -0,0 +1,151 @@ +#include "ColorScaleIO.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +namespace { +using geopro::core::Rgba; + +// 外部文件内容不可信:stod 对非数字/超界 token 会抛异常,统一兜成 nullopt。 +std::optional 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(v + 0.5); +} + +std::vector splitLines(const std::string& s) { + std::vector 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 parseLvl(const std::string& content) { + std::vector rows; + const auto lines = splitLines(content); + if (lines.empty() || lines[0].rfind("LVL", 0) != 0) return rows; // 头校验 + + // 行内扫描 "R G B[ 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 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(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& rows) { + std::ostringstream os; + os.precision(std::numeric_limits::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 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(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 diff --git a/src/app/ColorScaleIO.hpp b/src/app/ColorScaleIO.hpp new file mode 100644 index 0000000..0d2404f --- /dev/null +++ b/src/app/ColorScaleIO.hpp @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include + +#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 parseLvl(const std::string& content); +// 生成 .lvl 文本(LVL3 头 + 每行 14 列),与原系统互通。 +std::string generateLvl(const std::vector& rows); + +// .clr 连续色阶(复刻 colorEditor.vue import/export)。pos ∈ [0,1] 升序,opacity ∈ [0,1]。 +struct ClrData { + std::vector> 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 diff --git a/src/app/ContourLevelDialog.cpp b/src/app/ContourLevelDialog.cpp new file mode 100644 index 0000000..b1e30d1 --- /dev/null +++ b/src/app/ContourLevelDialog.cpp @@ -0,0 +1,254 @@ +#include "ContourLevelDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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::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(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(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(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 diff --git a/src/app/ContourLevelDialog.hpp b/src/app/ContourLevelDialog.hpp new file mode 100644 index 0000000..25d8cc2 --- /dev/null +++ b/src/app/ContourLevelDialog.hpp @@ -0,0 +1,58 @@ +#pragma once +#include + +#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 diff --git a/src/app/ContourLevels.cpp b/src/app/ContourLevels.cpp new file mode 100644 index 0000000..3f47284 --- /dev/null +++ b/src/app/ContourLevels.cpp @@ -0,0 +1,82 @@ +#include "ContourLevels.hpp" + +#include +#include + +namespace geopro::app { + +namespace { +constexpr int kMaxLevels = 100000; // 纯函数自身的 OOM 兜底(UI 另有 ≤50 校验) + +std::vector normalLevels(double mn, double mx, double interval) { + std::vector 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(lend); + for (int i = 0; i < len; ++i) levels.push_back(mn + interval * i); + return levels; +} + +std::vector logarithmicLevels(double mn, double mx, int logLines) { + std::vector 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 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 equalAreaLevels(double mn, double mx, int cnt, + const std::vector& samples) { + std::vector levels; + if (cnt <= 0) return levels; + std::vector z; + z.reserve(samples.size()); + for (double v : samples) + if (std::isfinite(v)) z.push_back(v); + if (static_cast(z.size()) >= cnt) { // 分位 + std::sort(z.begin(), z.end()); + const int chunk = static_cast(z.size()) / cnt; + for (int i = 0; i < cnt - 1; ++i) + levels.push_back( + z[static_cast(std::min(i * chunk, static_cast(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 generateContourLevels(const ContourLevelParams& p, + const std::vector& 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 diff --git a/src/app/ContourLevels.hpp b/src/app/ContourLevels.hpp new file mode 100644 index 0000000..86b84c7 --- /dev/null +++ b/src/app/ContourLevels.hpp @@ -0,0 +1,26 @@ +#pragma once +#include + +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 generateContourLevels(const ContourLevelParams& p, + const std::vector& samples); + +} // namespace geopro::app diff --git a/src/app/ContourLineDialog.cpp b/src/app/ContourLineDialog.cpp new file mode 100644 index 0000000..ecdcd53 --- /dev/null +++ b/src/app/ContourLineDialog.cpp @@ -0,0 +1,102 @@ +#include "ContourLineDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +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(c.red()), + static_cast(c.green()), + static_cast(c.blue()), + static_cast(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 diff --git a/src/app/ContourLineDialog.hpp b/src/app/ContourLineDialog.hpp new file mode 100644 index 0000000..3467d6f --- /dev/null +++ b/src/app/ContourLineDialog.hpp @@ -0,0 +1,42 @@ +#pragma once +#include + +#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 diff --git a/src/app/Glyphs.cpp b/src/app/Glyphs.cpp index 869db48..e9adc84 100644 --- a/src/app/Glyphs.cpp +++ b/src/app/Glyphs.cpp @@ -73,6 +73,14 @@ QString svgPathFor(Glyph t) return QStringLiteral(""); case Glyph::ChevronRight: return QStringLiteral(""); + case Glyph::WorkArea: // 工区:地图定位标记(Lucide map-pin) + return QStringLiteral( + ""); + case Glyph::SurveyLine: // 测线:折线(Lucide spline / line-chart 简化) + return QStringLiteral( + "" + ""); case Glyph::Workspace: return QStringLiteral( "" diff --git a/src/app/Glyphs.hpp b/src/app/Glyphs.hpp index 8a4b80a..d405a34 100644 --- a/src/app/Glyphs.hpp +++ b/src/app/Glyphs.hpp @@ -29,6 +29,9 @@ enum class Glyph { Fullscreen, // 全屏 / 最大化 ChevronLeft, // 折叠抽屉(向左) ChevronRight, // 展开抽屉(向右) + // 对象树类型图标(§6.1:GS 工区 / TM 测线) + WorkArea, // GS 检测对象(工区,地图定位标记) + SurveyLine, // TM 方法对象(测线,折线) // 顶部应用栏图标 Workspace, // 工作空间(2x2 宫格) Folder, // 项目(文件夹) diff --git a/src/app/GradientEditWidget.cpp b/src/app/GradientEditWidget.cpp new file mode 100644 index 0000000..d9656c4 --- /dev/null +++ b/src/app/GradientEditWidget.cpp @@ -0,0 +1,333 @@ +#include "GradientEditWidget.hpp" + +#include +#include + +#include +#include +#include +#include + +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& 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::stops() const { + std::vector sorted = nodes_; + std::sort(sorted.begin(), sorted.end(), + [](const Node& a, const Node& b) { return a.pos < b.pos; }); + std::vector 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 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 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 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(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 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 bins(kHistogramBins, 0); + const double span = maxValue_ - minValue_; + for (double v : samples_) { + if (v < minValue_ || v > maxValue_) continue; // 仅统计域内样本 + int idx = static_cast((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 diff --git a/src/app/GradientEditWidget.hpp b/src/app/GradientEditWidget.hpp new file mode 100644 index 0000000..4f0f91d --- /dev/null +++ b/src/app/GradientEditWidget.hpp @@ -0,0 +1,87 @@ +#pragma once +#include + +#include +#include + +#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& stops); // pos 升序,至少 2 个 + std::vector stops() const; // 升序导出 + void reverse(); // 反向:保持各 pos 不变,仅反转颜色序列 + + void setMinMax(double minValue, double maxValue); // 直方图域 + 读出域 + void setSamples(std::vector 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 nodes_; + int nextId_ = 0; + int selectedId_ = -1; + bool dragging_ = false; + + double minValue_ = 0.0; + double maxValue_ = 100.0; + std::vector samples_; +}; + +} // namespace geopro::app diff --git a/src/app/ObjectFormDialog.cpp b/src/app/ObjectFormDialog.cpp index 20018a1..7def586 100644 --- a/src/app/ObjectFormDialog.cpp +++ b/src/app/ObjectFormDialog.cpp @@ -122,12 +122,21 @@ void ObjectFormDialog::buildTopFields() { } auto* fl = new QFormLayout(topBox_); - fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, - geopro::app::space::kLg, 0); + // body 内距:对话框用 space/xl(24) 环绕表单(规范 §7.0.6)。 + fl->setContentsMargins(geopro::app::space::kXxl, geopro::app::space::kXxl, + geopro::app::space::kXxl, 0); fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - fl->setHorizontalSpacing(geopro::app::space::kMd); - fl->setVerticalSpacing(geopro::app::space::kSm); + fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg + 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(); @@ -136,7 +145,7 @@ void ObjectFormDialog::buildTopFields() { const QString label = confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型"); typeCombo_ = new QComboBox(topBox_); - fl->addRow(label, typeCombo_); + addRow(label, typeCombo_); QObject::connect(typeCombo_, qOverload(&QComboBox::currentIndexChanged), this, [this](int) { const QString tid = typeCombo_->currentData().toString(); @@ -150,18 +159,18 @@ void ObjectFormDialog::buildTopFields() { typeNameLabel_ = new QLabel(topBox_); geopro::app::applyTokenizedStyleSheet(typeNameLabel_, QStringLiteral("color:{{text/secondary}};")); - fl->addRow(QStringLiteral("类型"), typeNameLabel_); + addRow(QStringLiteral("类型"), typeNameLabel_); } nameEdit_ = new QLineEdit(topBox_); nameEdit_->setPlaceholderText(QStringLiteral("名称")); if (!isCreate) nameEdit_->setEnabled(false); // 编辑态名称禁用 - fl->addRow(QStringLiteral("名称"), nameEdit_); + addRow(QStringLiteral("名称"), nameEdit_); if (confType_ == kConfGs) { responsibleEdit_ = new QLineEdit(topBox_); responsibleEdit_->setPlaceholderText(QStringLiteral("负责人")); - fl->addRow(QStringLiteral("负责人"), responsibleEdit_); + addRow(QStringLiteral("负责人"), responsibleEdit_); } } @@ -271,11 +280,13 @@ QJsonObject ObjectFormDialog::buildBody() const { void ObjectFormDialog::onConfirm() { if (nameEdit_ && nameEdit_->text().trimmed().isEmpty()) { + if (nameEdit_->isEnabled()) nameEdit_->setFocus(Qt::OtherFocusReason); // 聚焦名称 QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写名称")); return; } QString missing; if (!editor_->validateRequired(&missing)) { + editor_->focusFirstInvalid(); // 规范 §7.0.5:聚焦第一个错误字段 QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写必填项:%1").arg(missing)); return; diff --git a/src/app/PanelHeader.cpp b/src/app/PanelHeader.cpp index 3566ef0..598d1fe 100644 --- a/src/app/PanelHeader.cpp +++ b/src/app/PanelHeader.cpp @@ -16,11 +16,12 @@ namespace geopro::app { namespace { -// ── 专业图标/字号尺寸(统一放大)── -constexpr int kHeaderHeight = 42; -constexpr int kTitleIcon = 20; // 表头标题图标 -constexpr int kActionIcon = 19; // 表头操作按钮图标 -constexpr int kTabIcon = 19; // Tab 图标 +// ── 表头图标/尺寸(规范 §4.3:高 36px、标题图标 14px、操作按钮 24×24 含 16px 图标)── +constexpr int kHeaderHeight = 36; // 表头高度(§4.3)。标准/Tab/分段表头共用,保持一致。 +constexpr int kTitleIcon = 14; // 表头标题图标(§4.3「14px 图标」) +constexpr int kActionIcon = 16; // 操作按钮内图标(§9「按钮内 16px」) +constexpr int kActionButton = 24; // 操作按钮命中区 24×24(§4.3 / §12 无障碍 ≥24×24) +constexpr int kTabIcon = 19; // Tab 图标 // 表头统一样式(标准表头 + Tab 表头共用)。字号/字重引用 Theme 排版令牌: // 面板标题=title(15)、徽标=caption(12)、Tab 文本=body(13),加粗统一 semibold。 @@ -37,7 +38,7 @@ QString headerQss() " padding:1px 7px; font-size:%2px; font-weight:%3; }" "#panelBadgeWarn { background:{{status/warning-bg}}; color:{{status/warning}}; border-radius:9px;" " 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#tabBtn { border:none; border-bottom:2px solid transparent; color:{{text/secondary}};" " padding:8px 6px; font-size:%4px; }" @@ -47,7 +48,8 @@ QString headerQss() .arg(scaledPx(type::kTitle)) // %1 标题字号 .arg(scaledPx(type::kCaption)) // %2 徽标字号 .arg(type::kWeightSemibold) // %3 字重(多处) - .arg(scaledPx(type::kBody)); // %4 页签字号 + .arg(scaledPx(type::kBody)) // %4 页签字号 + .arg(radius::kSm); // %5 操作按钮悬停底圆角(§3.2) } // 数量徽标(默认隐藏,调用方 setText+setVisible 显示)。 @@ -68,7 +70,9 @@ QWidget* makeActionButton(QWidget* parent, const HeaderAction& a) btn->setObjectName(QStringLiteral("panelAction")); btn->setProperty("glyphId", static_cast(a.first)); // 供调用方按图标定位并连接真实功能 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->setToolTip(a.second + QStringLiteral("(占位)")); btn->setAutoRaise(true); @@ -81,7 +85,7 @@ QWidget* buildPanelHeader(Glyph icon, const QString& title, const QVector
setObjectName(QStringLiteral("panelHeader")); - header->setFixedHeight(kHeaderHeight); + header->setFixedHeight(scaledPx(kHeaderHeight)); geopro::app::applyTokenizedStyleSheet(header, headerQss()); auto* lay = new QHBoxLayout(header); @@ -114,7 +118,7 @@ TabbedPanel buildTabbedPanel(const QVector& tabs, const QVector
setObjectName(QStringLiteral("panelHeader")); - header->setFixedHeight(kHeaderHeight); + header->setFixedHeight(scaledPx(kHeaderHeight)); geopro::app::applyTokenizedStyleSheet(header, headerQss()); auto* hlay = new QHBoxLayout(header); hlay->setContentsMargins(10, 0, 8, 0); @@ -169,7 +173,7 @@ SegmentedHeader buildSegmentedHeader(const QVector& segments, { auto* header = new QWidget(); header->setObjectName(QStringLiteral("panelHeader")); - header->setFixedHeight(kHeaderHeight); + header->setFixedHeight(scaledPx(kHeaderHeight)); geopro::app::applyTokenizedStyleSheet(header, headerQss()); auto* hlay = new QHBoxLayout(header); diff --git a/src/app/SettingsDialog.cpp b/src/app/SettingsDialog.cpp index d10f106..fcac9f9 100644 --- a/src/app/SettingsDialog.cpp +++ b/src/app/SettingsDialog.cpp @@ -2,41 +2,79 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include #include +#include "Glyphs.hpp" #include "Theme.hpp" namespace geopro::app { namespace { -// 「标签 + 控件」一行(标签定宽左对齐,控件右随)。 -QWidget* makeRow(const QString& label, QWidget* control) { +// 设置项行(§7.10):左 = 标题(text/body) + 可选说明(text/caption · text/tertiary) 竖叠; +// 右 = 控件(开关/下拉/输入)。caption 为空则只显标题。 +QWidget* makeRow(const QString& label, QWidget* control, const QString& caption = QString()) { auto* row = new QWidget(); auto* lay = new QHBoxLayout(row); lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(12); - auto* lbl = new QLabel(label, row); - lbl->setMinimumWidth(96); - lay->addWidget(lbl); - lay->addWidget(control, 1); + lay->setSpacing(geopro::app::space::kLg); + + // 左侧标题列:标题在上,说明(可选)在下。 + auto* labelCol = new QWidget(row); + 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; } -// 区段标题。 -QLabel* sectionTitle(const QString& text, QWidget* parent) { - auto* t = new QLabel(text, parent); - t->setStyleSheet(QStringLiteral("font-size:%1px; font-weight:600;") - .arg(geopro::app::scaledPx(geopro::app::type::kHeading))); - return t; +// 分组标题(§7.10):text/heading 字号 + text/secondary 色,下方 1px divider。 +QWidget* sectionTitle(const QString& text, QWidget* parent) { + auto* box = new QWidget(parent); + auto* v = new QVBoxLayout(box); + v->setContentsMargins(0, 0, 0, 0); + 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() { @@ -56,7 +94,8 @@ QWidget* buildAppearancePage() { QObject::connect(themeCombo, &QComboBox::activated, page, [themeCombo](int) { geopro::app::setThemeModePreference(themeCombo->currentData().toString()); }); - v->addWidget(makeRow(QStringLiteral("主题"), themeCombo)); + v->addWidget(makeRow(QStringLiteral("主题"), themeCombo, + QStringLiteral("跟随系统 / 浅色 / 深色,切换即时生效"))); // 界面字号:小/标准/大/特大(重启生效)。 auto* fontCombo = new QComboBox(page); @@ -66,12 +105,13 @@ QWidget* buildAppearancePage() { fontCombo->addItem(QStringLiteral("特大"), 130); const int curScale = geopro::app::fontScalePreference(); 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* rlay = new QHBoxLayout(restartRow); - rlay->setContentsMargins(96 + 12, 0, 0, 0); // 与控件列对齐 + rlay->setContentsMargins(160 + geopro::app::space::kLg, 0, 0, 0); // 与控件列对齐 rlay->setSpacing(10); auto* hint = new QLabel(QStringLiteral("界面字号将在重启后生效"), restartRow); geopro::app::applyTokenizedStyleSheet( @@ -135,12 +175,36 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) { root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); - // 左:分类列表。 + // 左:分类导航(§7.10)。分类项高 32px,图标 + 名称;选中 = bg/selected 底 + 左 2px 竖条。 + // 左竖条用「选中项 2px 左边框 + accent/primary」实现,非选中项留同宽透明左边框防文字跳动。 auto* sidebar = new QListWidget(this); sidebar->setObjectName(QStringLiteral("settingsSidebar")); - sidebar->setFixedWidth(150); - sidebar->addItem(QStringLiteral("外观")); - sidebar->addItem(QStringLiteral("关于")); + sidebar->setFixedWidth(160); + sidebar->setIconSize(QSize(geopro::app::scaledPx(16), geopro::app::scaledPx(16))); + 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); // 右:分页。 diff --git a/src/app/Theme.cpp b/src/app/Theme.cpp index 5354e2c..ee59d9c 100644 --- a/src/app/Theme.cpp +++ b/src/app/Theme.cpp @@ -216,7 +216,7 @@ QPushButton { background: {{bg/panel}}; color: {{text/primary}}; border: 1px solid {{border/strong}}; - border-radius: 6px; + border-radius: 4px; /* radius/sm */ padding: 6px 14px; } QPushButton:hover { @@ -239,17 +239,24 @@ QPushButton:disabled { color: {{text/disabled}}; 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 { background: {{bg/panel}}; color: {{text/primary}}; - border: 1px solid {{border/strong}}; - border-radius: 6px; + border: 1px solid {{border/default}}; + border-radius: 4px; /* radius/sm */ padding: 6px 8px; + min-height: 16px; selection-background-color: {{accent/primary}}; selection-color: {{text/on-primary}}; } +QLineEdit:hover { + border-color: {{border/strong}}; +} QLineEdit:focus { - border: 1px solid {{accent/primary}}; + border: 1px solid {{border/focus}}; } QLineEdit:disabled { background: {{bg/app}}; @@ -330,7 +337,7 @@ QMenuBar { QMenuBar::item { background: transparent; padding: 6px 12px; - border-radius: 6px; + border-radius: 4px; /* radius/sm */ } QMenuBar::item:selected { background: {{bg/hover}}; @@ -343,11 +350,12 @@ QMenu { background: {{bg/panel}}; color: {{text/primary}}; border: 1px solid {{border/default}}; + border-radius: 6px; /* radius/md(浮层容器) */ padding: 4px; } QMenu::item { padding: 6px 24px 6px 14px; - border-radius: 6px; + border-radius: 4px; /* radius/sm */ } QMenu::item:selected { 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 { background: {{bg/panel}}; color: {{text/primary}}; - border: 1px solid {{border/strong}}; - border-radius: 6px; + border: 1px solid {{border/default}}; + border-radius: 4px; /* radius/sm */ padding: 6px 10px; - min-height: 18px; + min-height: 16px; } QComboBox:hover { - border-color: {{accent/primary}}; + border-color: {{border/strong}}; } QComboBox:focus { - border-color: {{accent/primary}}; + border-color: {{border/focus}}; } QComboBox::drop-down { border: none; @@ -381,6 +391,7 @@ QComboBox::drop-down { QComboBox QAbstractItemView { background: {{bg/panel}}; border: 1px solid {{border/default}}; + border-radius: 6px; /* radius/md(浮层容器) */ outline: none; padding: 2px; } @@ -424,6 +435,36 @@ QProgressBar::chunk { 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 停靠:标题栏做成「固定面板表头」(对齐原型)────────────── 面板已锁定(无关闭/浮动/拖动),每个区只一个标签即表头:统一浅色头 + 蓝色下划线强调 + 深色加粗标题。各区一致,读起来像固定子窗口表头。 */ diff --git a/src/app/Theme.hpp b/src/app/Theme.hpp index 5148b72..a798d48 100644 --- a/src/app/Theme.hpp +++ b/src/app/Theme.hpp @@ -3,11 +3,9 @@ // 全局视觉主题(浅色专业方向):Fusion 风格 + 浅色 QPalette + 结构化 QSS。 // 仅外观——不改任何信号槽 / 渲染 / 数据逻辑。在 QApplication 构造后、弹登录窗前调用一次。 // -// 设计令牌(与登录窗、视图详情浮层共享,保证全项目一脉相承): -// 外壳底 #F4F6FA 面板白 #FFFFFF 抬升/表头 #EDF1F7 -// 强调 #2D6CB5 悬停 #2862A6 按下 #234F87 选中行 #DCE9F8 -// 文字 #1F2A3D 次要文字 #5A6B85 边框 #D5DBE5 强边框 #C2CCDA -// 危险 #C0392B +// 设计令牌唯一事实来源 = Theme.cpp 的 kTokens 表(规范 §1.5)。组件只引语义 token +// (token()/{{token}}),禁止在此散落硬编码 hex。速查:bg/app #F7F8FA · accent/primary +// #3B73EC · text/primary #272C35 · border/default #E3E6EB · status/danger #E5484D。 #include #include @@ -56,6 +54,10 @@ inline constexpr int kWeightMedium = 500; inline constexpr int kWeightSemibold = 600; inline constexpr int kWeightBold = 700; +// 等宽字族(规范 §2.1):坐标/数值/编号/深度刻度用,保证逐列对齐。 +// 仅在此暴露,组件阶段(数值/坐标/ID 标签)按需引用,不在全局 QSS 强加。 +inline constexpr const char* kMonoFamily = "Cascadia Code, JetBrains Mono, Consolas, monospace"; + } // namespace type // ── 间距令牌(全项目唯一间距阶)────────────────────────────────── @@ -75,33 +77,28 @@ inline constexpr int kXl = 16; // 区块内边距 inline constexpr int kXxl = 24; // 区块间距、表单纵向边距 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 -// ── 圆角令牌(统一原先 4/5/6/7/8/9 共 6 档为 3 档)──────────────── +// ── 圆角令牌(规范 §3.2)──────────────────────────────────────── // 圆形元素(头像等)用 直径/2 单独写字面量,不入档。 namespace radius { -inline constexpr int kSm = 6; // 按钮·输入·菜单项·滚动条·进度条 -inline constexpr int kMd = 8; // 卡片·面板·对话框·菜单·分组框 -inline constexpr int kPill = 9; // 数量徽标胶囊 +inline constexpr int kSm = 4; // 按钮·输入框·标签 +inline constexpr int kMd = 6; // 卡片·列表项·浮层·菜单 +inline constexpr int kLg = 8; // 对话框·画布浮窗·分组框 +inline constexpr int kPill = 999; // 胶囊标签·开关·计数徽标 } // 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 主题桥用)。 // 暗色复用同一 QSS 结构,颜色全由 kTokens 双值(fillTokens/tokenHex)驱动;幂等,可随主题切换重复调用。 void applyThemeMode(QApplication& app, bool dark); diff --git a/src/app/ToastOverlay.cpp b/src/app/ToastOverlay.cpp new file mode 100644 index 0000000..e30eef6 --- /dev/null +++ b/src/app/ToastOverlay.cpp @@ -0,0 +1,107 @@ +#include "ToastOverlay.hpp" + +#include +#include +#include +#include +#include + +#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(QStringLiteral("toastCard")); + if (!overlay) overlay = new ToastOverlay(win); + overlay->showMessage(msg); +} + +} // namespace geopro::app diff --git a/src/app/ToastOverlay.hpp b/src/app/ToastOverlay.hpp new file mode 100644 index 0000000..e04fde1 --- /dev/null +++ b/src/app/ToastOverlay.hpp @@ -0,0 +1,42 @@ +#pragma once + +// 浮动轻提示(规范 §7.7 Toast):底部居中浮出的小卡片,bg/panel 底 + 1px border/default 边框 +// + 左侧 3px 状态色竖条(默认 status/info)+ 文案;~3500ms 自动消失,点击立即关闭。 +// 纯外观组件,不触碰任何业务/渲染/数据逻辑。 +// +// 实现取「单例可复用」方案:每个顶层窗口共用一个 ToastOverlay 子控件,新消息直接替换当前文案 +// 并重置计时器——避免多实例叠放带来的悬垂指针风险。作为锚窗口的子控件,随窗口移动/缩放自动跟随。 +// Qt QSS 画不出阴影,故以「边框 + 半透明底」近似浮层高度,不强行模拟 box-shadow。 + +#include + +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 diff --git a/src/app/TopBar.cpp b/src/app/TopBar.cpp index 159bf1b..f337438 100644 --- a/src/app/TopBar.cpp +++ b/src/app/TopBar.cpp @@ -10,10 +10,10 @@ #include #include #include -#include #include #include #include +#include #include #include #include @@ -27,9 +27,9 @@ namespace geopro::app { namespace { -// ── 专业图标尺寸(统一放大;菜单栏字号亦同步加大)── -constexpr int kToolIcon = 22; // 工具条右侧图标 -constexpr int kWorkspaceIcon = 20; // 工作空间 / 项目图标 +// ── 工具条图标尺寸(贴近常见桌面客户端:16px 紧凑)── +constexpr int kToolIcon = 16; // 工具条右侧图标 +constexpr int kWorkspaceIcon = 16; // 工作空间 / 项目图标 // 竖直分隔细线。 QFrame* makeDivider(QWidget* parent) @@ -38,7 +38,7 @@ QFrame* makeDivider(QWidget* parent) line->setObjectName(QStringLiteral("topDivider")); line->setFrameShape(QFrame::VLine); line->setFixedWidth(1); - line->setFixedHeight(24); + line->setFixedHeight(20); return line; } @@ -55,6 +55,46 @@ QWidget* makeIconButton(QWidget* parent, Glyph icon, const QString& tip) 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 清晰。 QPixmap renderAvatar(const QString& initials, int px, const QColor& bg, const QColor& fg) { @@ -141,44 +181,37 @@ QMenu* buildDeviceMenu(QWidget* p) } // 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) { setObjectName(QStringLiteral("appToolBar")); - setFixedHeight(56); - // 字号引用 Theme 排版令牌:工作空间切换器=title(15)、头像/用户名=body·label(13)、 - // 角色名=caption(12)。原 11px 角色名上调到 12,去掉只差 1px 的糊层级。 + setFixedHeight(40); + // 字号引用 Theme 排版令牌:切换器/一级菜单按钮=body(13)、头像/用户名=body·label(13)、 + // 角色名=caption(12)。工具条整体收紧到常见桌面客户端尺寸(行高 40 / 图标 16 / 字号 13)。 // 切换器(ElaToolButton)/图标(ElaIconButton) 自绘 Fluent,不再写它们的 QSS。 - // 仅保留:工具条底/分隔线、头像(圆形自定义)、用户名/角色。头像白字用 {{text/on-primary}} 令牌。 - // 切换器下拉箭头:用生成的高清 chevron PNG 作 menu-indicator(替代旧的粗糙文字箭头),中性灰双主题可读。 + // 仅保留:工具条底/分隔线、一级菜单按钮、头像(圆形自定义)、用户名/角色。头像白字用 {{text/on-primary}} 令牌。 + // 切换器/菜单按钮下拉箭头:用生成的高清 chevron PNG 作 menu-indicator(中性灰双主题可读)。 const QString chevron = geopro::app::writeChevronIcon(true, QColor("#7C8493")); geopro::app::applyTokenizedStyleSheet( this, QStringLiteral( "#appToolBar { background:{{bg/header}}; border-bottom:1px solid {{divider}}; }" "#topDivider { color:{{divider}}; }" "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; }" "#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; }" - "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}}; }" "#userBtn { border:none; border-radius:8px; padding:4px 10px 4px 6px;" " color:{{text/primary}}; font-size:%3px; }" "#userBtn:hover { background:{{bg/hover}}; }" "#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; }" "#userName { color:{{text/primary}}; font-size:%3px; font-weight:%4; }" "#userRole { color:{{text/tertiary}}; font-size:%5px; }") @@ -187,7 +220,7 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { .arg(scaledPx(type::kLabel)) .arg(type::kWeightSemibold) .arg(scaledPx(type::kCaption)) - .arg(scaledPx(type::kTitle)) + .arg(scaledPx(type::kBody)) .arg(chevron)); auto* lay = new QHBoxLayout(this); @@ -220,10 +253,25 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { projBtn_->setMenu(new QMenu(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->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("设置")); if (auto* gb = qobject_cast(gearBtn)) QObject::connect(gb, &QAbstractButton::clicked, this, [this] { emit settingsRequested(); }); @@ -240,13 +288,13 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { userRow_->setCursor(Qt::PointingHandCursor); userRow_->installEventFilter(this); auto* uLay = new QHBoxLayout(userRow_); - uLay->setContentsMargins(8, 3, 8, 3); - uLay->setSpacing(10); + uLay->setContentsMargins(8, 2, 8, 2); + uLay->setSpacing(8); auto* avatar = new QLabel(userRow_); avatar->setPixmap( - renderAvatar(QStringLiteral("ZL"), 34, geopro::app::tokenColor("accent/primary"), Qt::white)); - avatar->setFixedSize(34, 34); + renderAvatar(QStringLiteral("ZL"), 30, geopro::app::tokenColor("accent/primary"), Qt::white)); + avatar->setFixedSize(30, 30); avatar->setAttribute(Qt::WA_TransparentForMouseEvents); uLay->addWidget(avatar, 0, Qt::AlignVCenter); diff --git a/src/app/TopBar.hpp b/src/app/TopBar.hpp index 87315d0..36e800b 100644 --- a/src/app/TopBar.hpp +++ b/src/app/TopBar.hpp @@ -9,10 +9,7 @@ class QMenu; namespace geopro::app { -// 顶部菜单栏(静态,本轮不接真实页面)。 -QWidget* buildMenuBar(QWidget* parent); - -// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。 +// 顶部工具条:数据驱动的工作空间/项目切换器 + 一级菜单按钮 + 右侧图标 + 用户区。 class TopBar : public QWidget { Q_OBJECT public: diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index 4c60571..68eae4b 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -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) { auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_, verticalExaggeration_); diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index 82286eb..02eb295 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -33,11 +33,14 @@ public: void clear() override; void setVerticalExaggeration(double ve) override; + double zRefElev() const override { return zRefElev_; } void addSurveyLine(const geopro::core::Grid& grid) override; void addCurtain(const std::string& dsId, const geopro::core::Grid& grid, const geopro::core::ColorScale& cs) override; void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, 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 removeDataset(const std::string& dsId) override; void addAnomaly(const geopro::core::Anomaly& a) override; diff --git a/src/app/main.cpp b/src/app/main.cpp index 91135fa..d7314fe 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -101,6 +101,7 @@ #include "SettingsDialog.hpp" #include "SlicePropertiesDialog.hpp" #include "SliceExport.hpp" +#include "ToastOverlay.hpp" #include "TopBar.hpp" #include "VolumeParamsDialog.hpp" #include "VolumePropertiesDialog.hpp" @@ -121,6 +122,7 @@ #include "panels/chart/GridStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" +#include "api/ApiColorTemplateRepository.hpp" #include "api/Api3dRepository.hpp" #include "panels/ObjectTreePanel.hpp" #include "login/LoginWindow.hpp" @@ -167,6 +169,8 @@ #include #include #include +#include +#include #include #include #include @@ -229,6 +233,7 @@ constexpr const char* kWgs84 = "EPSG:4326"; void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, geopro::data::IAsyncProjectRepository& projectRepo, geopro::data::IAsyncDatasetRepository& datasetRepo, + geopro::data::IColorTemplateRepository& colorTplRepo, geopro::controller::WorkbenchNavController& nav, geopro::controller::DatasetDetailController& detailCtrl) { @@ -773,7 +778,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。 // 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。 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(); if (sceneView->currentVolumeDsId() != dsId || !sceneView->hasVolume()) { QMessageBox::information( @@ -781,9 +786,29 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QStringLiteral("请先勾选该三维体使其渲染后再编辑色阶。")); return; } + // 等积分层需原始标量:从当前体素 image 抽取(无则等积退化线性)。 + // 大体素按步长抽样(等积分位无需全量点),避免主线程长循环卡 UI。 + std::vector 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(n / stride + 1)); + for (vtkIdType i = 0; i < n; i += stride) + samples.push_back(sc->GetComponent(i, 0)); + } + } + } + // 3D 无等值线,线形/标注配置忽略(用默认);仅取色阶应用。 + // 「另存为/打开」与「新建色阶/配色方案」走色阶模板仓储,projectId 取当前项目。 geopro::app::ColorScaleConfigDialog dlg( sceneView->currentColorScale(), sceneView->currentVmin(), - sceneView->currentVmax(), &window); + sceneView->currentVmax(), std::move(samples), {}, &colorTplRepo, + nav.currentProjectId(), &window); if (dlg.exec() == QDialog::Accepted) sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale()); }); @@ -829,6 +854,26 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re : geopro::app::TileBasemap::Hidden; 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(0.0); + auto view2dMode = std::make_shared(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 后,把选中的底图加载到数据所在位置 // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 sceneView->onFrameReanchored = [basemap, basemapKind]() { @@ -898,6 +943,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 下方「数据详情」dock:平面图表多 Tab 面板(QGraphicsView,VTK 仅算几何)── // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 auto* detailPanel = new geopro::app::DatasetDetailPanel(); + // 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。 + detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); }); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); // ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。 // 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充; @@ -1099,19 +1146,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, sceneCtrl, [sceneCtrl]() { sceneCtrl->rebuild(); }); - // 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备), - // 下=工具条(工作空间切换 + 项目 + 帮助/通知/设置 + 用户)。纵向堆叠后挂到主窗口顶部。 - geopro::app::TopBar* topBar = nullptr; - { - 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); - } + // 顶部应用区:单行工具条(工作空间/项目切换 + 一级菜单按钮 视图/项目管理/业务工具/设备 + // + 帮助/通知/设置 + 用户)。菜单栏已去除,一级菜单改为工具条上的下拉按钮。 + geopro::app::TopBar* topBar = new geopro::app::TopBar(&window); + window.setMenuWidget(topBar); // ── 控制器 ↔ UI 信号接线(导航壳)────────────────────────────────────── // "加载更多"行:列表末尾若已加载数 < 总数,放一行可点击的"加载更多(已/共)"。 @@ -1202,8 +1240,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 交互操作接线(对象/数据集视图:右键菜单 + 快速筛选 + 增删,2D/3D 相关占位)──────── auto* anomalyTabGroup = anomalyPanel.tabGroup; // 捕指针(anomalyPanel 为局部,勿按引用捕获) - // 状态栏轻提示(toast 替代;window 生命周期覆盖整个会话,按引用捕获安全)。 - auto toast = [&window](const QString& msg) { window.statusBar()->showMessage(msg, 4000); }; + // 浮动轻提示(规范 §7.7 Toast:底部居中浮出小卡片;window 生命周期覆盖整个会话,按引用捕获安全)。 + auto toast = [&window](const QString& msg) { geopro::app::showToast(&window, msg); }; // 表头操作按钮定位器:按 glyphId 在已包装面板表头里找具体 QToolButton。 auto findHeaderAction = [](QWidget* box, geopro::app::Glyph g) -> QToolButton* { const int gid = static_cast(g); @@ -1278,19 +1316,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QStringLiteral("确定删除「%1」?该操作不可撤销。").arg(name), QMessageBox::Yes | QMessageBox::No, QMessageBox::No); 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")) { // 新建 TM:对话框拉 tmList(全局方法类型)选类型 → getDynamicForm(type=2) → POST /tmObject。 // 父对象:在 GS/项目根上=该节点;在 TM 上=其父 GS/根(即新建同级 TM)。 @@ -1354,7 +1379,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re [&window](const QString& msg) { auto* sb = window.statusBar(); 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); QTimer::singleShot(6000, sb, [sb]() { sb->setStyleSheet(QString()); }); }); @@ -1399,16 +1424,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }); dlg->open(); }; - // 按选中类型决定菜单项:选 项目根/GS → 新建GS+TM;选 TM → 仅新建TM(同级)。 - // 父对象由 currentParentForNew() 统一给出(TM→父GS、GS/根→自身、未选→根),三种情况均正确。 + // 选 项目根/GS → 可新建 GS+TM。选中 TM 时按钮已被禁用(测线下不能新增对象), + // 故此处仅处理非 TM;父对象由 currentParentForNew() 给出(GS/根→自身、未选→根)。 QMenu m(objectTree); - if (objectTree->currentSelectedConfType() != 2) // 非 TM:可新建检测对象(GS) - m.addAction(QStringLiteral("新建检测对象"), objectTree, - [openForm]() { openForm(true); }); + m.addAction(QStringLiteral("新建检测对象"), objectTree, + [openForm]() { openForm(true); }); m.addAction(QStringLiteral("新建方法对象"), objectTree, [openForm]() { openForm(false); }); 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&) { + objAddBtn->setEnabled(true); + }); } // 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。 @@ -1635,7 +1668,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 状态栏错误反馈:临时染 danger 红,8s 后随消息超时还原全局中性色。 auto* sb = window.statusBar(); 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); QTimer::singleShot(8000, sb, [sb]() { sb->setStyleSheet(QString()); }); }); @@ -1812,6 +1845,8 @@ int main(int argc, char* argv[]) // 数据详情仓储 + 控制器(接真实反演 API):同一共享会话 ApiClient。 geopro::data::ApiDatasetRepository datasetRepo(api); + // 色阶模板仓储(lvl 模板 + clr 色阶):同一共享会话 ApiClient,注入 2D/3D 色阶编辑器。 + geopro::data::ApiColorTemplateRepository colorTplRepo(api); // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 geopro::controller::ChartStrategyRegistry chartRegistry; chartRegistry.add(std::make_unique()); @@ -1831,7 +1866,7 @@ int main(int argc, char* argv[]) // 启动防护:工作台构建期间任何同步加载失败(如样本数据缺失)都不应让进程静默退出, // 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。 try { - buildWorkbench(*window, repo, projectRepo, datasetRepo, nav, detailCtrl); + buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, nav, detailCtrl); } catch (const std::exception& e) { QMessageBox::critical( nullptr, QStringLiteral("启动失败"), diff --git a/src/app/panels/AnomalyListPanel.cpp b/src/app/panels/AnomalyListPanel.cpp index f2eb677..4a676f4 100644 --- a/src/app/panels/AnomalyListPanel.cpp +++ b/src/app/panels/AnomalyListPanel.cpp @@ -54,6 +54,41 @@ QColor barColor(const QString& s) 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) { @@ -95,14 +130,20 @@ public: const bool selected = opt.state & QStyle::State_Selected; const bool hover = opt.state & QStyle::State_MouseOver; - // 卡底(hover/选中高亮) - if (selected || hover) { - QPainterPath path; path.addRoundedRect(r, 6, 6); - p->fillPath(path, geopro::app::tokenColor(selected ? "bg/selected" : "bg/hover")); - } - // 左 3px 状态色竖条(取异常自身 lineColor) + // 分级状态键(§6.3):高=Danger 中=Warning 低=Info 未知=Neutral。 + const QString status = idx.data(kAnomalyStatusRole).toString(); + + // 卡底(§6.3 规范§3.2 radius/md=6):静止态 = 该分级状态浅底;选中/hover 叠一档 + // 交互态(选中=bg/selected 强调底,hover=bg/hover),让交互可辨又不丢分级语义。 + 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), - barColor(idx.data(kAnomalyColorRole).toString())); + statusColor(status, /*bg=*/false)); const QString name = idx.data(Qt::DisplayRole).toString(); const QString type = idx.data(kAnomalyTypeRole).toString(); @@ -112,18 +153,24 @@ public: const int right = anomalyEyeRect(opt.rect).left() - 8; // 给眼睛留位 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); p->setFont(nf); 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->fontMetrics().elidedText(name, Qt::ElideRight, rowW)); + p->fontMetrics().elidedText(name, Qt::ElideRight, nameR.width())); - // 第二行:类型胶囊 + 摘要 + // 第二行:分级胶囊标签 + 摘要 int x = left; const int cy = r.top() + 38; if (!type.isEmpty()) { + // 等级标签(§6.8):胶囊 radius/pill(按高度算半径),底 = 状态浅底,文字 = 状态主色。 QFont pf = opt.font; pf.setPixelSize(geopro::app::scaledPx(11)); p->setFont(pf); const QFontMetrics fm(pf); @@ -131,13 +178,16 @@ public: const int ph = fm.height() + 2; const QRect pill(x, cy - ph / 2, tw + 12, ph); QPainterPath pp; pp.addRoundedRect(pill, ph / 2.0, ph / 2.0); - p->fillPath(pp, geopro::app::tokenColor("bg/hover")); - p->setPen(geopro::app::tokenColor("text/secondary")); + p->fillPath(pp, statusColor(status, /*bg=*/true)); + p->setPen(statusColor(status, /*bg=*/false)); p->drawText(pill, Qt::AlignCenter, type); x = pill.right() + 8; } 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->setPen(geopro::app::tokenColor("text/secondary")); const QRect sumR(x, cy - 10, right - x, 20); @@ -183,11 +233,25 @@ void populateAnomalyList(QListWidget* list, const std::vectorsetData(kAnomalyColorRole, QString::fromStdString(a.lineColor)); item->setData(kAnomalyTypeRole, QString::fromStdString(a.typeName)); item->setData(kAnomalySummaryRole, summarize(a)); + item->setData(kAnomalyStatusRole, anomalyStatus(a)); // 分级状态键(驱动卡底/标签/竖条同色) item->setFlags(item->flags() | Qt::ItemIsUserCheckable); 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) { if (!list) return; diff --git a/src/app/panels/AnomalyListPanel.hpp b/src/app/panels/AnomalyListPanel.hpp index ce6e6b2..1952c50 100644 --- a/src/app/panels/AnomalyListPanel.hpp +++ b/src/app/panels/AnomalyListPanel.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include + #include "model/Anomaly.hpp" class QListWidget; @@ -14,6 +16,7 @@ constexpr int kAnomalyIndexRole = 0x0100; // Qt::UserRole constexpr int kAnomalyColorRole = 0x0101; // lineColor 字符串 constexpr int kAnomalyTypeRole = 0x0102; // typeName constexpr int kAnomalySummaryRole = 0x0103; // 位置·深·尺寸 摘要 +constexpr int kAnomalyStatusRole = 0x0104; // 分级状态键(danger/warning/info/neutral,§6.3) // 给异常列表套用卡片委托(左色条+名称+类型标签+摘要+右侧显隐眼睛,规范§6.3)。 void applyAnomalyCardDelegate(QListWidget* list); @@ -24,4 +27,9 @@ void applyAnomalyCardDelegate(QListWidget* list); // 清空旧条目后重填。 void populateAnomalyList(QListWidget* list, const std::vector& anomalies); +// 「异常列表 可见/总数」计数文本(规范§6.3:标题计数=勾选可见数/总数,如 "2/3")。 +// 供宿主面板表头/徽标显示——卡片列表本身不含标题栏,标题由外层 buildTabbedPanel 持有, +// 故此处只产出计数串,由调用方(监听列表勾选变化)写回标题或徽标。 +QString anomalyVisibleCountText(QListWidget* list); + } // namespace geopro::app diff --git a/src/app/panels/DatasetAttrPanel.cpp b/src/app/panels/DatasetAttrPanel.cpp index 8fde6b2..4d1e869 100644 --- a/src/app/panels/DatasetAttrPanel.cpp +++ b/src/app/panels/DatasetAttrPanel.cpp @@ -32,7 +32,7 @@ DatasetAttrPanel::DatasetAttrPanel(geopro::data::IAsyncProjectRepository& repo, auto* descLay = new QVBoxLayout(descBox); descLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm, 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); geopro::app::applyTokenizedStyleSheet(hint, QStringLiteral("color:{{text/secondary}};")); diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 16bfd00..fb5fccd 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -1,5 +1,7 @@ #include "panels/DatasetDetailPage.hpp" +#include + #include #include @@ -18,6 +20,12 @@ DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { lay->setSpacing(0); } +void DatasetDetailPage::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter) { + colorTplRepo_ = repo; + projectIdGetter_ = std::move(projectIdGetter); +} + void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs) { Q_ASSERT(views_.empty()); // build() 仅一次:views_ 为已 release 的裸指针,二次构建会泄漏旧视图 @@ -35,7 +43,9 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QVector panelTabs; for (size_t i = 0; i < tabs.size(); ++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 父子树接管生命周期 views_[i] = raw; // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。 diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index 98dc161..3b487f6 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -1,10 +1,16 @@ #pragma once +#include #include #include +#include #include #include #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec +namespace geopro::data { +class IColorTemplateRepository; +} + namespace geopro::app { class IDetailView; @@ -17,6 +23,10 @@ class DatasetDetailPage : public QWidget { public: explicit DatasetDetailPage(QWidget* parent = nullptr); + // 色阶模板仓储 + projectId 取值回调(注入网格剖面色阶编辑器,须在 build 前设置)。 + void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter); + // 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。 void build(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs); @@ -49,6 +59,10 @@ private: std::vector loaded_; // 各页签是否已加载(避免重复请求) std::vector requested_; // lazy 页签是否已请求过 QMap overlays_; // lazy 页签的加载遮罩(覆盖该视图) + + // 色阶模板仓储注入(透传给 makeDetailView → 网格剖面色阶编辑器)。 + geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; + std::function projectIdGetter_; }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 4688d62..4df5a9a 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -1,7 +1,16 @@ #include "panels/DatasetDetailPanel.hpp" + +#include + #include "panels/DatasetDetailPage.hpp" namespace geopro::app { +void DatasetDetailPanel::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter) { + colorTplRepo_ = repo; + projectIdGetter_ = std::move(projectIdGetter); +} + DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { setTabsClosable(true); 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); if (!p) { p = new DatasetDetailPage(this); + // 注入须在 build 前(build 内造视图时即透传给工厂)。 + p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_); p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id) const int idx = addTab(p, title); diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index 1707a5d..f02a51a 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -1,8 +1,13 @@ #pragma once +#include #include +#include #include #include #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec +namespace geopro::data { +class IColorTemplateRepository; +} namespace geopro::app { class DatasetDetailPage; @@ -12,6 +17,10 @@ class DatasetDetailPanel : public QTabWidget { public: explicit DatasetDetailPanel(QWidget* parent = nullptr); + // 色阶模板仓储 + projectId 取值回调:透传给每个新建的详情页(网格剖面色阶编辑器用)。 + void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter); + // 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。 void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs); @@ -29,5 +38,9 @@ signals: private: DatasetDetailPage* pageFor(const QString& dsId) const; + + // 色阶模板仓储注入(新页 build 前 setColorTemplateRepo 透传)。 + geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; + std::function projectIdGetter_; }; } // namespace geopro::app diff --git a/src/app/panels/DynamicFormEditor.cpp b/src/app/panels/DynamicFormEditor.cpp index ae93415..8cabb14 100644 --- a/src/app/panels/DynamicFormEditor.cpp +++ b/src/app/panels/DynamicFormEditor.cpp @@ -8,10 +8,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -54,7 +56,7 @@ QString labelText(const data::EditField& f) { QString t = QString::fromStdString(f.name); if (f.required == kRequiredYes) t += QStringLiteral(" *") - .arg(QString::fromUtf8(geopro::app::semantic::kDanger)); + .arg(geopro::app::token("status/danger")); return t; } @@ -132,7 +134,7 @@ QWidget* buildWidget(const data::EditField& f) { auto* te = new QPlainTextEdit(); te->setPlainText(val); te->setFixedHeight(geopro::app::scaledPx(64)); - if (ro) te->setReadOnly(true); + if (ro) te->setEnabled(false); // 只读:灰显禁用 return te; } case kCompNumber: { @@ -143,7 +145,7 @@ QWidget* buildWidget(const data::EditField& f) { } else { applyIntRange(le, f); } - if (ro) le->setReadOnly(true); + if (ro) le->setEnabled(false); // 只读:灰显禁用(明确不可编辑,对齐原版 a-input disabled) return le; } case kCompText: @@ -158,7 +160,7 @@ QWidget* buildWidget(const data::EditField& f) { } else if (f.dataType == kDtFloat) { le->setValidator(new QDoubleValidator(le)); } - if (ro) le->setReadOnly(true); + if (ro) le->setEnabled(false); // 只读:灰显禁用(明确不可编辑,对齐原版 a-input disabled) return le; } } @@ -202,6 +204,12 @@ bool widgetEmpty(int comp, QWidget* w) { if (comp == kCompCheckbox) return false; // 复选框总有值 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 DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) { @@ -210,35 +218,109 @@ DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) { lay->setSpacing(0); } -void DynamicFormEditor::setForm(const data::EditableForm& form) { +void DynamicFormEditor::clear() { entries_.clear(); if (body_) { body_->deleteLater(); body_ = nullptr; } +} + +void DynamicFormEditor::setForm(const data::EditableForm& form, + const QSet& hiddenFieldNames) { + clear(); body_ = new QWidget(this); auto* outer = new QVBoxLayout(body_); outer->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, geopro::app::space::kLg, 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(w)) + connect(x, &QCheckBox::toggled, this, [this] { emit changed(); }); + break; + case kCompSelect: + case kCompTreeSelect: + if (auto* x = qobject_cast(w)) + connect(x, &QComboBox::currentTextChanged, this, [this] { emit changed(); }); + break; + case kCompDate: + if (auto* x = qobject_cast(w)) + connect(x, &QDateEdit::dateChanged, this, [this] { emit changed(); }); + break; + case kCompTime: + if (auto* x = qobject_cast(w)) + connect(x, &QTimeEdit::timeChanged, this, [this] { emit changed(); }); + break; + case kCompDateTime: + if (auto* x = qobject_cast(w)) + connect(x, &QDateTimeEdit::dateTimeChanged, this, [this] { emit changed(); }); + break; + case kCompMultiline: + if (auto* x = qobject_cast(w)) + connect(x, &QPlainTextEdit::textChanged, this, [this] { emit changed(); }); + break; + default: + if (auto* x = qobject_cast(w)) + connect(x, &QLineEdit::textEdited, this, [this] { emit changed(); }); + break; + } + }; + + bool renderedGroup = false; // 已渲染过组标题 → 后续组上方留 space/lg 分隔 for (const auto& g : form.groups) { + // 先分流:隐藏字段保留提交值但不渲染;可见字段进 visible 用于布局。 + std::vector 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()) { + // 分组标题(规范 §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_); 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)); 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(); fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - fl->setHorizontalSpacing(geopro::app::space::kMd); - fl->setVerticalSpacing(geopro::app::space::kSm); - for (const auto& f : g.fields) { + fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg + fl->setVerticalSpacing(geopro::app::space::kMd); // 行距 ≈8px(项目 kMd) + for (const data::EditField* fp : visible) { + const data::EditField& f = *fp; QWidget* w = buildWidget(f); + capFieldWidth(f.comp, w); // 字段最大宽上限(§7.0.2 不要拉满) auto* lbl = new QLabel(labelText(f), body_); lbl->setTextFormat(Qt::RichText); // 允许 * 的红色 span + lbl->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kFormLabelCol)); // 等宽右标签列 fl->addRow(lbl, w); Entry e; e.code = QString::fromStdString(f.code); @@ -248,6 +330,7 @@ void DynamicFormEditor::setForm(const data::EditableForm& form) { e.readonly = isReadonly(f); e.widget = w; entries_.push_back(e); + if (!e.readonly) wireChanged(w, f.comp); // 仅可编辑字段挂脏标记 } outer->addLayout(fl); } @@ -257,7 +340,12 @@ void DynamicFormEditor::setForm(const data::EditableForm& form) { QMap DynamicFormEditor::collectValues() const { QMap 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; } @@ -272,4 +360,22 @@ bool DynamicFormEditor::validateRequired(QString* missingName) const { 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(a)) { + sa->ensureWidgetVisible(w); + break; + } + } + return; + } + } +} + } // namespace geopro::app diff --git a/src/app/panels/DynamicFormEditor.hpp b/src/app/panels/DynamicFormEditor.hpp index 38f706e..0697e80 100644 --- a/src/app/panels/DynamicFormEditor.hpp +++ b/src/app/panels/DynamicFormEditor.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -21,10 +22,18 @@ class DynamicFormEditor : public QWidget { public: explicit DynamicFormEditor(QWidget* parent = nullptr); - void setForm(const data::EditableForm& form); // 重建控件 + // 重建控件。hiddenFieldNames:按 fieldName 隐藏的字段(如与上层固定「名称」重复)—— + // 不渲染,但其值仍参与 collectValues(保留提交,避免后端清空)。 + void setForm(const data::EditableForm& form, const QSet& hiddenFieldNames = {}); + void clear(); // 清空控件与状态(切换/加载前去残留) QMap collectValues() const; // fieldCode → 当前值 // 校验必填:全部满足返回 true;否则返回 false 并把首个缺失字段名写入 *missingName。 bool validateRequired(QString* missingName) const; + // 聚焦第一个「必填且为空」的可编辑控件并滚动可见(规范 §7.0.5:聚焦第一个错误字段)。 + void focusFirstInvalid(); + +signals: + void changed(); // 任一可编辑控件被用户修改(上层据此启用「保存」脏标记) private: struct Entry { @@ -33,6 +42,8 @@ private: int comp = 1; bool required = false; bool readonly = false; // 只读(comp2 / requiredType2 / 核心测量值):不参与必填校验 + bool hidden = false; // 去重隐藏:不渲染,但 value 仍参与收集提交 + QString value; // hidden 时的固定提交值(widget 为空) QWidget* widget = nullptr; // 取值控件 }; QVector entries_; diff --git a/src/app/panels/DynamicFormView.cpp b/src/app/panels/DynamicFormView.cpp index a4e6c5a..2c11133 100644 --- a/src/app/panels/DynamicFormView.cpp +++ b/src/app/panels/DynamicFormView.cpp @@ -1,5 +1,6 @@ #include "panels/DynamicFormView.hpp" +#include #include #include #include @@ -19,23 +20,58 @@ constexpr int kColLabelB = 2; constexpr int kColValueB = 3; 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) { auto* k = new QLabel(text); k->setAlignment(Qt::AlignLeft | Qt::AlignTop); + k->setFixedWidth(geopro::app::scaledPx(kKeyColWidth)); // 定宽 72px,跨组对齐 + k->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); // 行高 28px geopro::app::applyTokenizedStyleSheet( k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;")); return k; } -// 字段值(主色、可换行、可选中复制)。 +// 字段值(主色、可换行、可选中复制)。数值/坐标/带单位值改用等宽字族(§6.4)。 QLabel* makeValue(const QString& text) { auto* v = new QLabel(text); v->setWordWrap(true); v->setAlignment(Qt::AlignLeft | Qt::AlignTop); 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( v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;")); return v; @@ -53,17 +89,19 @@ QFrame* makeRowDivider() return line; } -// 分组标题带:横跨整行的淡底强调条,半粗次要色,给表单清晰的层级。 +// 分组标题带:横跨整行的淡底强调条,标题级字号 + 半粗,给表单清晰的层级(§6.4 +// 「分组标题 text/heading,上留 space/md」)。上外边距用 space/md 与上一组拉开。 QLabel* makeGroupHeader(const QString& name) { auto* title = new QLabel(name); geopro::app::applyTokenizedStyleSheet( title, QStringLiteral("color:{{text/secondary}}; background:{{bg/hover}};" "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::scaledPx(geopro::app::type::kBody)) - .arg(geopro::app::radius::kSm)); + .arg(geopro::app::scaledPx(geopro::app::type::kHeading)) + .arg(geopro::app::radius::kSm) + .arg(geopro::app::space::kMd)); return title; } diff --git a/src/app/panels/ObjectAttrPanel.cpp b/src/app/panels/ObjectAttrPanel.cpp index 4f38bea..c03e85f 100644 --- a/src/app/panels/ObjectAttrPanel.cpp +++ b/src/app/panels/ObjectAttrPanel.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include "Theme.hpp" @@ -59,6 +60,10 @@ ObjectAttrPanel::ObjectAttrPanel(geopro::data::IAsyncProjectRepository& repo, QW lay->addLayout(btnRow); QObject::connect(saveBtn_, &QPushButton::clicked, this, &ObjectAttrPanel::onSave); + // 脏标记:动态字段被用户修改 → 启用「保存」(加载/未改时保持禁用)。 + QObject::connect(editor_, &DynamicFormEditor::changed, this, [this] { + if (!objectId_.isEmpty()) saveBtn_->setEnabled(true); + }); // 初始:无选中 → 隐藏编辑区,仅占位提示。 scroll_->setVisible(false); @@ -88,27 +93,36 @@ void ObjectAttrPanel::loadObject(const QString& projectId, const QString& typeId typeId_ = typeId; parentId_ = parentId; + editor_->clear(); // 去残留:切换/加载期间不显示上一个对象的字段 + + if (typeId.isEmpty()) { + // 无类型 → getDynamicForm 必「数据不存在」;直接给友好占位,避免报错 + 残留旧表单。 + showMessage(QStringLiteral("(该对象暂无可编辑属性)")); + return; + } + scroll_->setVisible(true); saveBtn_->setVisible(true); - saveBtn_->setEnabled(false); + saveBtn_->setEnabled(false); // 脏标记:未修改不可保存(等用户实际编辑) status_->setText(QStringLiteral("加载中…")); status_->setVisible(true); rebuildTopFields(); - if (nameEdit_) nameEdit_->setText(displayName); // 编辑态:名称预填(沿用对话框语义) + if (nameEdit_) nameEdit_->setText(displayName); // 名称只读预填(与原版一致) if (formReq_) formReq_->abort(); formReq_ = repo_.loadEditableFormAsync(typeId.toStdString(), objectId.toStdString(), confType, projectId_.toStdString()); QObject::connect(formReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) { const auto form = qvariant_cast(v); - editor_->setForm(form); + // 隐藏动态表单里与顶部固定「名称」重复的字段(值仍随 properties 提交,不丢)。 + editor_->setForm(form, QSet{QStringLiteral("名称")}); status_->setVisible(false); - saveBtn_->setEnabled(true); + // 不在此启用保存——保持禁用,直到用户真正修改某字段(changed 信号)。 }); 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); fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - fl->setHorizontalSpacing(geopro::app::space::kMd); - fl->setVerticalSpacing(geopro::app::space::kSm); + fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg + 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_->setEnabled(false); // 编辑态名称禁用(与对话框一致) - fl->addRow(QStringLiteral("名称"), nameEdit_); + addRow(QStringLiteral("名称"), nameEdit_); if (confType_ == kConfGs) { responsibleEdit_ = new QLineEdit(topBox_); responsibleEdit_->setPlaceholderText(QStringLiteral("负责人")); - fl->addRow(QStringLiteral("负责人"), responsibleEdit_); + // 负责人可编辑 → 纳入脏标记。 + QObject::connect(responsibleEdit_, &QLineEdit::textEdited, this, + [this] { saveBtn_->setEnabled(true); }); + addRow(QStringLiteral("负责人"), responsibleEdit_); } } void ObjectAttrPanel::onSave() { QString missing; if (!editor_->validateRequired(&missing)) { + editor_->focusFirstInvalid(); // 规范 §7.0.5:聚焦第一个错误字段 QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写必填项:%1").arg(missing)); return; @@ -182,7 +208,7 @@ void ObjectAttrPanel::onSave() { confType_, false, QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString()); QObject::connect(saveReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) { status_->setVisible(false); - saveBtn_->setEnabled(true); + saveBtn_->setEnabled(false); // 保存成功 → 回到「无未保存修改」态 emit saved(); }); QObject::connect(saveReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) { diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index 32bc913..b452627 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -1,14 +1,19 @@ #include "panels/ObjectTreePanel.hpp" #include +#include #include #include #include #include +#include #include +#include #include +#include #include #include +#include #include #include #include @@ -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 kRoleTypeId = Qt::UserRole + 4; // 类型 id(编辑调 getDynamicForm 用) 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 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 挂根), // 携带其 id/typeId,可右键 新建GS/TM/属性;勾选随 2D/3D 批次暂不开放。 void addNodes(QTreeWidgetItem* parent, const std::vector& nodes, @@ -39,19 +105,27 @@ void addNodes(QTreeWidgetItem* parent, const std::vectorsetText(0, QString::fromStdString(n.node.name)); item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); 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) { // 项目根:作为 GS 承载(id 携带),不可勾选;菜单仅 新建GS/TM/属性。 item->setData(0, kRoleConfType, kConfTypeGs); item->setData(0, kRoleIsRoot, true); + item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx)); } else if (n.isTm) { item->setData(0, kRoleConfType, kConfTypeTm); item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setCheckState(0, Qt::Unchecked); + item->setIcon(0, makeGlyph(Glyph::SurveyLine, iconColor, iconPx)); } else { item->setData(0, kRoleConfType, kConfTypeGs); // GS item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); 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(n.children.size())); addNodes(item, n.children, false); // 子层永远非顶层 } } @@ -65,7 +139,10 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { // Qt 原生标准树:复选框/展开箭头由 Fusion 绘制(明暗都清晰);配色取自全局主题 QSS。 tree_ = new QTreeWidget(this); 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); @@ -149,16 +226,14 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { add(QStringLiteral("属性"), QStringLiteral("properties")); add(QStringLiteral("异常详情"), QStringLiteral("exceptionDetail")); menu.addSeparator(); - add(QStringLiteral("编辑"), QStringLiteral("edit")); + // 「编辑」弹窗通道已移除:GS/TM 统一走「属性」面板(右上对象属性)就地编辑,避免双编辑入口。 if (isGs) { // GS 节点:新建检测对象 / 新建方法对象。(TM 节点上不显示「新建检测对象」——xlsx:tm 上新建GS 无效。) add(QStringLiteral("新建检测对象"), QStringLiteral("newGs")); add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); } if (isTm) { - // TM 节点:仅「新建方法对象」(同级,父=该 TM 的父 GS/根)+ 导入 DS。 - // (xlsx:tm 上新建GS 无效,故不显示「新建检测对象」。) - add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); + // TM 节点:不提供任何「新建」(测线下不能新增对象)——仅「导入数据集」。 add(QStringLiteral("导入数据集…"), QStringLiteral("importDs")); } menu.addSeparator(); diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index 663bd61..8dcf982 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -182,12 +182,13 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt return QPointF(xMap.transform(p.x), yMap.transform(p.y)); }; - // 2) 等值线:黑色 0 宽(cosmetic)细线。 + // 2) 等值线:按线形⚙ 配置取色/虚实(默认黑实线)。 if (showLines_ && !lines_.empty()) { painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); - QPen pen(QColor(0, 0, 0)); - pen.setWidthF(1.0); // 1px 黑色等值线 + QPen pen(QColor(lineColor_.r, lineColor_.g, lineColor_.b, lineColor_.a)); + pen.setWidthF(1.0); // 1px 等值线 + pen.setStyle(lineDashed_ ? Qt::DashLine : Qt::SolidLine); painter->setPen(pen); for (const auto& ln : lines_) { if (ln.pts.size() < 2) continue; @@ -205,7 +206,7 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt QFont f = painter->font(); f.setPixelSize(kLabelFontPx); painter->setFont(f); - painter->setPen(QColor(0, 0, 0)); + painter->setPen(QColor(labelColor_.r, labelColor_.g, labelColor_.b, labelColor_.a)); const QFontMetricsF fm(f); for (const auto& ln : lines_) { if (ln.pts.size() < 2 || std::isnan(ln.level)) continue; diff --git a/src/app/panels/chart/ContourPlotItem.hpp b/src/app/panels/chart/ContourPlotItem.hpp index 5052773..06db8de 100644 --- a/src/app/panels/chart/ContourPlotItem.hpp +++ b/src/app/panels/chart/ContourPlotItem.hpp @@ -33,6 +33,10 @@ public: void setShowLines(bool on) { showLines_ = on; } void setShowLabels(bool on) { showLabels_ = 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; } @@ -53,6 +57,9 @@ private: bool showLines_ = true; bool showLabels_ = true; bool showAnomalies_ = true; + core::Rgba lineColor_{0, 0, 0, 255}; // 等值线色(默认黑) + bool lineDashed_ = false; // 等值线虚实(默认实线) + core::Rgba labelColor_{0, 0, 0, 255}; // 标注色(默认黑) }; } // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp index 5185893..8ac1ca5 100644 --- a/src/app/panels/chart/DetailViewFactory.cpp +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -1,6 +1,7 @@ #include "panels/chart/DetailViewFactory.hpp" #include +#include #include "panels/chart/BarChartView.hpp" #include "panels/chart/DataTableView.hpp" @@ -11,12 +12,18 @@ namespace geopro::app { -std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent) { +std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent, + geopro::data::IColorTemplateRepository* colorTplRepo, + std::function projectIdGetter) { switch (kind) { case controller::ViewKind::Scatter: return std::unique_ptr(new RawDataChartView(parent)); - case controller::ViewKind::FilledContour: - return std::unique_ptr(new GridDataChartView(parent)); + case controller::ViewKind::FilledContour: { + auto* grid = new GridDataChartView(parent); + // 注入色阶模板仓储 + projectId 取值回调(网格剖面「色阶配置」编辑器用)。 + grid->setColorTemplateRepo(colorTplRepo, std::move(projectIdGetter)); + return std::unique_ptr(grid); + } case controller::ViewKind::Table: return std::unique_ptr(new DataTableView(parent)); case controller::ViewKind::Bar: diff --git a/src/app/panels/chart/DetailViewFactory.hpp b/src/app/panels/chart/DetailViewFactory.hpp index 98464b0..2cba058 100644 --- a/src/app/panels/chart/DetailViewFactory.hpp +++ b/src/app/panels/chart/DetailViewFactory.hpp @@ -1,9 +1,17 @@ #pragma once +#include #include + +#include + #include "DatasetDetailTab.hpp" // geopro::controller::ViewKind class QWidget; +namespace geopro::data { +class IColorTemplateRepository; +} + namespace geopro::app { class IDetailView; @@ -11,6 +19,10 @@ class IDetailView; // 按 render kind 造详情视图。E1b 仅支持 Scatter / FilledContour(反演两页签); // Bar/LineProfile/PolylineMap/Table 留待后续阶段(measurement/trajectory/grid)补, // 现阶段命中会抛 std::runtime_error(明确失败,而非静默空指针)。 -std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent); +// colorTplRepo/projectIdGetter:FilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。 +std::unique_ptr makeDetailView( + controller::ViewKind kind, QWidget* parent, + geopro::data::IColorTemplateRepository* colorTplRepo = nullptr, + std::function projectIdGetter = {}); } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index d268977..0a56446 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -1,7 +1,10 @@ #include "panels/chart/GridDataChartView.hpp" +#include + #include #include +#include #include #include #include @@ -14,6 +17,7 @@ #include #include +#include "ColorScaleConfigDialog.hpp" #include "PanelHeader.hpp" #include "Theme.hpp" #include "panels/AnomalyTablePanel.hpp" @@ -54,6 +58,7 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { auto* chkShowContourLabel = new QCheckBox(QStringLiteral("显示等值线标注"), toolbar); chkShowContourLabel->setChecked(true); + chkShowLabels_ = chkShowContourLabel; // 存成员:线形⚙ 改标注显隐后回写复选框 UI auto* chkContourTip = new QCheckBox(QStringLiteral("显示等值线提示信息"), toolbar); chkContourTip->setChecked(false); @@ -165,6 +170,9 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { showLabels_ = on; if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); } }); + // 「色阶配置」→ 共享色阶编辑器(与三维体右键「色阶」同一对话框)。 + connect(btnColorScale, &QToolButton::clicked, this, + [this]() { openColorScaleEditor(); }); // 主题配色:当前主题套一次 + 监听切换热更新。 applyChartPlotTheme(plot_); @@ -217,8 +225,11 @@ void GridDataChartView::rebuildContour() { } contourItem_ = new ContourPlotItem(); - contourItem_->setData(grid_, colorSvc_, anoms_, /*showLines*/ true, showLabels_); + contourItem_->setData(grid_, colorSvc_, anoms_, lineCfg_.lineShow, showLabels_); contourItem_->setShowAnomalies(showAnomalies_); + contourItem_->setLineColor(lineCfg_.lineColor); // 线形⚙ 配置 + contourItem_->setLineDashed(lineCfg_.dashed); + contourItem_->setLabelColor(lineCfg_.labelColor); contourItem_->attach(plot_); // 轴范围 = 数据范围(x=距离、y=深度/高程)。 @@ -232,4 +243,35 @@ void GridDataChartView::rebuildContour() { plot_->replot(); } +void GridDataChartView::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter) { + tplRepo_ = repo; + projectIdGetter_ = std::move(projectIdGetter); +} + +void GridDataChartView::openColorScaleEditor() { + if (!hasGrid_) return; + // 数据范围始终取网格真实值域(不取编辑后色阶端点,否则等积兜底/新增插值会用错区间)。 + std::vector 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 diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index ab8c6d4..177a917 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include "model/Anomaly.hpp" @@ -8,12 +10,18 @@ #include "model/Field.hpp" #include "model/detail/DetailPayloads.hpp" #include "panels/chart/IDetailView.hpp" +#include "ContourLineDialog.hpp" // ContourLineConfig(线形/标注状态) class QSlider; class QLabel; +class QCheckBox; class QwtPlot; class QwtPlotRescaler; +namespace geopro::data { +class IColorTemplateRepository; +} + namespace geopro::app { class AnomalyTablePanel; @@ -39,8 +47,14 @@ public: QWidget* widget() override { return this; } void setPayload(const QVariant& payload) override; + // 注入色阶模板仓储 + 当前 projectId 取值回调(打开编辑器时取一次,随项目切换生效)。 + // 可传空仓储 → 编辑器内「另存为/打开」「新建色阶」禁用。 + void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter); + private: void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem + void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙) QwtPlot* plot_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr; @@ -49,6 +63,7 @@ private: DescriptionPanel* descriptionPanel_ = nullptr; QSlider* simplifySlider_ = nullptr; QLabel* simplifyValueLabel_ = nullptr; + QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步) // 渲染状态 ColorMapService* colorSvc_ = nullptr; // heap,setGridData 重建 @@ -61,6 +76,11 @@ private: // 工具条显隐开关 bool showAnomalies_ = true; bool showLabels_ = true; + ContourLineConfig lineCfg_; // 线形/标注配置(色阶编辑器 线形⚙ 下发) + + // 色阶模板仓储 + projectId 取值回调(注入;空则编辑器后端按钮禁用)。 + geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; + std::function projectIdGetter_; }; } // namespace geopro::app diff --git a/src/controller/I3dSceneView.hpp b/src/controller/I3dSceneView.hpp index ffcdea0..2d6daf4 100644 --- a/src/controller/I3dSceneView.hpp +++ b/src/controller/I3dSceneView.hpp @@ -24,6 +24,8 @@ public: virtual void clear() = 0; virtual void setVerticalExaggeration(double ve) = 0; + // 地表高程基准(测线地表高程):2D 足迹「顶部/底部」摆放锚定真实地表。 + virtual double zRefElev() const = 0; // 2D:俯视测线红线(z=0)。 virtual void addSurveyLine(const geopro::core::Grid& grid) = 0; @@ -33,6 +35,9 @@ public: // 3D:体绘制(IDW 体素 + colorScale);按 dsId 跟踪。 virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, 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 地形 + 影像纹理。 virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0; // 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。 diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index 9d5bd1f..bbbca89 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -11,6 +11,13 @@ namespace geopro::controller { +namespace { +// 二维足迹「顶部/底部」摆放相对参考高程(Z=0)的偏移(米):控制器无地形/参考高程源 +// (地形异步、帘面经纬未必到场),故退化为 Z=0 上/下固定偏移,使足迹不与帘面顶/底面重叠遮挡。 +constexpr double kTopOffsetZ = 50.0; // 顶部:参考面上方 +constexpr double kBottomOffsetZ = -50.0; // 底部:参考面下方 +} // namespace + VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo, I3dSceneView& view, QObject* parent) @@ -46,6 +53,84 @@ void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算 } +void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) { + std::vector newDs; + newDs.reserve(static_cast(dsIds.size())); + for (const QString& id : dsIds) newDs.push_back(id.toStdString()); + + // 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff(不全量重建,不打断 3D 帘面/体)。 + const std::set oldSet(checked2dDs_.begin(), checked2dDs_.end()); + const std::set 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 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) { if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求 QPointer self(this); @@ -109,6 +194,10 @@ bool VtkSceneController::isChecked(const std::string& dsId) const { 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) { mode_ = mode; rebuildInternal(); @@ -130,6 +219,19 @@ void VtkSceneController::setVerticalExaggeration(double ve) { 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) { axesMode_ = mode; rebuildInternal(); // 坐标轴随场景重建(clear 会移除旧坐标轴 prop) @@ -190,6 +292,9 @@ void VtkSceneController::rebuildInternal() { }); } 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) { emit loadFailed(QString::fromStdString(e.what())); diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index d645402..b4d16d7 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -37,11 +37,19 @@ public: public slots: 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 setLayer(SceneLayer layer, bool on); void setVerticalExaggeration(double ve); void rebuild(); // 主题切换等外部触发的重渲染 + // 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。 + // 后端 3D 色阶保存未就绪 → 缓存即会话级 mock 持久(再勾选命中 volumeScaleCache_)。 + void setVolumeColorScale(const std::string& dsId, const geopro::core::ColorScale& cs); + // ── P2 三维数据集栏 ── void setAxesMode(AxesMode mode); void setAxesUnit(AxesUnit unit); @@ -57,14 +65,24 @@ private: void rebuildInternal(); // 增量加入单个 ds(帘面/体素,按图层开关);回调按 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 落地后:增量渲染 + 首批数据自动取景 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::I3dSceneRepository& sceneRepo_; I3dSceneView& view_; std::vector checkedDs_; + // 二维足迹勾选集(与 checkedDs_ 独立;都画进 View3D 场景)。按 dsId 增量加/删。 + std::vector checked2dDs_; + // 二维足迹摆放:mode 0关闭/1 Z=0/2顶部/3底部/4自定义;customZ2d_ 仅 mode=4 用。 + int placement2dMode_ = 0; + double customZ2d_ = 0.0; ViewMode mode_ = ViewMode::Map2D; bool showCurtain_ = true; bool showVoxel_ = false; diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 09478ae..8a168c3 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(geopro_data STATIC dto/GridDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp + api/ApiColorTemplateRepository.cpp api/Api3dRepository.cpp api/DatasetLoadHandles.cpp api/NavRequest.cpp) diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index 40fd3bb..0e7b0fb 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -36,7 +36,11 @@ DsDimension Api3dRepository::dimensionOf(const DsRow& ds) const { return DsDimension::Dim3D; } 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; } @@ -64,6 +68,34 @@ void Api3dRepository::loadSection(const std::string& dsId, std::function 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(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 { return volumes_.find(dsId) != volumes_.end(); } diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index 17df72f..d0eb9ba 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -67,6 +67,8 @@ public: OnError onErr) override; void loadSection(const std::string& dsId, std::function onOk, OnError onErr) override; + void loadMapLine(const std::string& dsId, std::function onOk, + OnError onErr) override; void loadTerrainPaths(std::function onOk, OnError onErr) override; // 切片 CRUD(后端未就绪 → 变更走 onErr) diff --git a/src/data/api/ApiColorTemplateRepository.cpp b/src/data/api/ApiColorTemplateRepository.cpp new file mode 100644 index 0000000..1819c5f --- /dev/null +++ b/src/data/api/ApiColorTemplateRepository.cpp @@ -0,0 +1,97 @@ +#include "api/ApiColorTemplateRepository.hpp" + +#include + +#include +#include + +#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 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 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 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 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 diff --git a/src/data/api/ApiColorTemplateRepository.hpp b/src/data/api/ApiColorTemplateRepository.hpp new file mode 100644 index 0000000..3aa5e55 --- /dev/null +++ b/src/data/api/ApiColorTemplateRepository.hpp @@ -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 cb) override; + void listLvlTemplates(const QString& projectId, + std::function cb) override; + void newClrScheme(const QString& projectId, const QJsonObject& properties, + std::function cb) override; + void listClrSchemes(const QString& projectId, + std::function cb) override; + +private: + net::ApiClient& api_; +}; + +} // namespace geopro::data diff --git a/src/data/repo/I3dSceneRepository.hpp b/src/data/repo/I3dSceneRepository.hpp index feba269..95e0953 100644 --- a/src/data/repo/I3dSceneRepository.hpp +++ b/src/data/repo/I3dSceneRepository.hpp @@ -35,6 +35,14 @@ struct SectionData { geopro::core::ColorScale scale; }; +// 二维足迹折线(测线/轨迹):一串 WGS84 经纬度点(lat[i]/lon[i] 一一对应)。 +// 渲染时经 Scene 共享 GeoLocalFrame 投影到世界 XY、Z=摆放高程,平铺进 3D 地图。 +// 不含色阶/深度(足迹是纯几何线,区别于帘面的 Grid+色阶)。 +struct MapLine { + std::vector lat, lon; + bool valid() const { return lat.size() == lon.size() && lat.size() >= 2; } +}; + // 三维场景仓储抽象(异步,spec §6 评审 HIGH)。 // 取数方法走回调 std::function(LocalSample 本地数据同步算好后直接回调; // 将来 Api3dRepository 在网络完成时回调,上层不变)。 @@ -66,6 +74,12 @@ public: virtual void loadSection(const std::string& dsId, std::function onOk, OnError onErr) = 0; + // 异步:加载二维足迹折线(测线/轨迹的 WGS84 经纬序列)。 + // Api 实现走 ERT 轨迹端点(dd/ert/trajectory/line)异步回调;本地样本给 mock 折线同步回调。 + // 契约同上:onOk/onErr 必须在主线程调用。 + virtual void loadMapLine(const std::string& dsId, + std::function onOk, OnError onErr) = 0; + // 异步:加载地形 DEM/影像路径。 virtual void loadTerrainPaths(std::function onOk, OnError onErr) = 0; diff --git a/src/data/repo/IColorTemplateRepository.hpp b/src/data/repo/IColorTemplateRepository.hpp new file mode 100644 index 0000000..7f31ec4 --- /dev/null +++ b/src/data/repo/IColorTemplateRepository.hpp @@ -0,0 +1,37 @@ +#pragma once +#include + +#include +#include +#include + +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 cb) = 0; + + // 列 lvl 模板:POST /business/lvlTemplate/page(pageNo=1,pageSize=1000)。 + // 回调 list = 响应 data.list 数组(每项含 templateName/properties)。 + virtual void listLvlTemplates(const QString& projectId, + std::function cb) = 0; + + // 新建 clr 色阶:POST /business/clr/colorGradation。 + virtual void newClrScheme(const QString& projectId, const QJsonObject& properties, + std::function cb) = 0; + + // 列 clr 色阶:GET .../queryCLRColorGradation/{projectId}。 + // 注意:响应 data 顶层为数组,回调 list = 该数组(每项含 properties.name/colorscale)。 + virtual void listClrSchemes(const QString& projectId, + std::function cb) = 0; +}; + +} // namespace geopro::data diff --git a/src/data/repo/LocalSample3dRepository.cpp b/src/data/repo/LocalSample3dRepository.cpp index 9d61fbf..a13f9bc 100644 --- a/src/data/repo/LocalSample3dRepository.cpp +++ b/src/data/repo/LocalSample3dRepository.cpp @@ -49,8 +49,11 @@ DsDimension LocalSample3dRepository::dimensionOf(const DsRow& ds) const { } // 切片:三维分析栏。 if (c == "dd_slice") return DsDimension::Analysis3D; - // 轨迹:二维数据集。 - if (c == "dd_trajectory_data") return DsDimension::Dim2D; + // 足迹型(测线/各类轨迹):二维数据集(与 Api3dRepository 同口径)。 + 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; } @@ -122,6 +125,25 @@ void LocalSample3dRepository::loadSection(const std::string& /*dsId*/, } } +void LocalSample3dRepository::loadMapLine(const std::string& /*dsId*/, + std::function 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 onOk, OnError onErr) { try { diff --git a/src/data/repo/LocalSample3dRepository.hpp b/src/data/repo/LocalSample3dRepository.hpp index 1b2f7d4..45e3bd2 100644 --- a/src/data/repo/LocalSample3dRepository.hpp +++ b/src/data/repo/LocalSample3dRepository.hpp @@ -30,6 +30,8 @@ public: OnError onErr) override; void loadSection(const std::string& dsId, std::function onOk, OnError onErr) override; + void loadMapLine(const std::string& dsId, std::function onOk, + OnError onErr) override; void loadTerrainPaths(std::function onOk, OnError onErr) override; // 切片 CRUD(spec §6.3 内存态 stub) diff --git a/src/render/ColorLutBuilder.cpp b/src/render/ColorLutBuilder.cpp index c4df643..45d067a 100644 --- a/src/render/ColorLutBuilder.cpp +++ b/src/render/ColorLutBuilder.cpp @@ -11,6 +11,8 @@ vtkSmartPointer buildLut(const geopro::core::ColorScale& cs, dou for (int t = 0; t < n; ++t) { const double val = vmin + (vmax - vmin) * t / (n - 1); 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->Build(); diff --git a/src/render/actors/CurtainActor.cpp b/src/render/actors/CurtainActor.cpp index 82467e0..0e065e2 100644 --- a/src/render/actors/CurtainActor.cpp +++ b/src/render/actors/CurtainActor.cpp @@ -1,16 +1,21 @@ #include "actors/CurtainActor.hpp" #include +#include +#include #include #include #include #include #include +#include #include #include +#include #include #include +#include #include #include @@ -19,10 +24,6 @@ namespace geopro::render { -namespace { -// LUT 级数。 -constexpr int kLutLevels = 256; -} // namespace vtkSmartPointer buildCurtain(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, @@ -110,8 +111,6 @@ vtkSmartPointer buildCurtain(const geopro::core::Grid& g, } } - auto lut = buildLut(cs, vmin, vmax, kLutLevels); - // structuredGrid → 表面 polydata(消隐格已剔除) → banded contour(分段色带,色带#18)。 vtkNew surf; surf->SetInputData(sgrid); @@ -123,13 +122,40 @@ vtkSmartPointer buildCurtain(const geopro::core::Grid& g, } else { 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> 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& s) { return val < s.first; }); + return it->second; // 第一个 value > v 的 stop = 上界 + }; + vtkNew shaded; + shaded->DeepCopy(banded->GetOutput()); + vtkNew 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 mapper; - mapper->SetInputConnection(banded->GetOutputPort()); + mapper->SetInputData(shaded); mapper->SetScalarModeToUseCellData(); - mapper->SetLookupTable(lut); - mapper->SetScalarRange(vmin, vmax); + mapper->SetColorModeToDirectScalars(); // cell 标量即 RGB,不再过 LUT auto actor = vtkSmartPointer::New(); actor->SetMapper(mapper); diff --git a/src/render/actors/MapLineActor.cpp b/src/render/actors/MapLineActor.cpp index 3e445ea..7e21f66 100644 --- a/src/render/actors/MapLineActor.cpp +++ b/src/render/actors/MapLineActor.cpp @@ -47,4 +47,39 @@ vtkSmartPointer buildSurveyLine(const geopro::core::Grid& g, return actor; } +vtkSmartPointer buildMapLine(const std::vector& lat, + const std::vector& lon, double worldZ, + const geopro::core::GeoLocalFrame& frame) +{ + const std::size_t n = lat.size(); + if (n < 2 || lon.size() != n) return vtkSmartPointer::New(); + + vtkNew 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 line; + line->GetPointIds()->SetNumberOfIds(static_cast(n)); + for (std::size_t i = 0; i < n; ++i) + line->GetPointIds()->SetId(static_cast(i), static_cast(i)); + + vtkNew cells; + cells->InsertNextCell(line); + + vtkNew poly; + poly->SetPoints(points); + poly->SetLines(cells); + + vtkNew mapper; + mapper->SetInputData(poly); + + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + actor->GetProperty()->SetColor(0.95, 0.55, 0.10); // 足迹:橙(与轨迹地图标记一致) + actor->GetProperty()->SetLineWidth(3.0); + return actor; +} + } // namespace geopro::render diff --git a/src/render/actors/MapLineActor.hpp b/src/render/actors/MapLineActor.hpp index 048d2ae..8e319f4 100644 --- a/src/render/actors/MapLineActor.hpp +++ b/src/render/actors/MapLineActor.hpp @@ -1,4 +1,6 @@ #pragma once +#include + #include #include @@ -12,4 +14,11 @@ namespace geopro::render { vtkSmartPointer buildSurveyLine(const geopro::core::Grid& g, const geopro::core::GeoLocalFrame& frame); +// 二维足迹折线(平铺进 3D 地图):把 lat/lon 序列经 frame 投影到世界 XY,Z=worldZ。 +// 与 buildSurveyLine 同口径(同 frame → 与帘面/底图同系配准),但 z 可控、不依赖 Grid。 +// 点数 < 2 或 lat/lon 长度不一致 → 返回空 actor(调用方自行判空)。 +vtkSmartPointer buildMapLine(const std::vector& lat, + const std::vector& lon, double worldZ, + const geopro::core::GeoLocalFrame& frame); + } // namespace geopro::render diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7c5661d..2358282 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -124,6 +124,16 @@ target_sources(geopro_tests PRIVATE ) # 散点 hover 文本格式(inline 纯函数,无需链 qwt/实例化 ScatterHoverTip)。 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 依赖)。 target_sources(geopro_tests PRIVATE app/test_dataset_dimension.cpp diff --git a/tests/app/test_color_scale_io.cpp b/tests/app/test_color_scale_io.cpp new file mode 100644 index 0000000..62c82b6 --- /dev/null +++ b/tests/app/test_color_scale_io.cpp @@ -0,0 +1,74 @@ +#include + +#include +#include + +#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 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()); +} diff --git a/tests/app/test_contour_levels.cpp b/tests/app/test_contour_levels.cpp new file mode 100644 index 0000000..4af4708 --- /dev/null +++ b/tests/app/test_contour_levels.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include + +#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,末 samples; + for (int i = 0; i < 100; ++i) samples.push_back(static_cast(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 +} diff --git a/tests/controller/test_vtk_scene_controller.cpp b/tests/controller/test_vtk_scene_controller.cpp index b238ebe..95e2fae 100644 --- a/tests/controller/test_vtk_scene_controller.cpp +++ b/tests/controller/test_vtk_scene_controller.cpp @@ -25,6 +25,7 @@ struct FakeView : I3dSceneView { int surveyLines = 0; int curtains = 0; int volumes = 0; + int mapLines = 0; int terrains = 0; int renders = 0; bool lastIs2D = false; @@ -43,26 +44,47 @@ struct FakeView : I3dSceneView { // 按 ds 记录图元数,使 removeDataset 能按量回退(反映增量场景状态)。 std::map> perDs; // dsId → (curtains, volumes) + std::map perDsMapLines; // dsId → mapLine 数(removeDataset 回退用) + double lastMapLineZ = 0.0; // 最近一次 addMapLine 的 worldZ(摆放验证) + double refElev = 0.0; // 地表高程基准(顶/底摆放锚定) + + // 色阶编辑:记录最近一次 addVolume 收到的色阶 + removeDataset 调用数(验证就地重渲染)。 + core::ColorScale lastVolumeScale; + int removeCalls = 0; // clear 模型化"移除所有数据图元":计数归零,clears 累加。 void clear() override { ++clears; - surveyLines = curtains = volumes = terrains = 0; + surveyLines = curtains = volumes = mapLines = terrains = 0; perDs.clear(); + perDsMapLines.clear(); } void setVerticalExaggeration(double v) override { ve = v; } + double zRefElev() const override { return refElev; } void addSurveyLine(const core::Grid&) override { ++surveyLines; } void addCurtain(const std::string& dsId, const core::Grid&, const core::ColorScale&) override { ++curtains; ++perDs[dsId].first; } void addVolume(const std::string& dsId, const data::VolumeGrid&, - const core::ColorScale&) override { + const core::ColorScale& cs) override { ++volumes; ++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 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); if (it == perDs.end()) return; curtains -= it->second.first; @@ -142,6 +164,13 @@ struct FakeSceneRepo : data::I3dSceneRepository { s.scale.addStop(1.0, core::Rgba{255, 0, 0, 255}); onOk(std::move(s)); // 同步回调(异步壳) } + void loadMapLine(const std::string&, std::function 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 onOk, OnError) override { onOk(data::TerrainPaths{"dem.tif", "image.tif"}); } @@ -267,6 +296,50 @@ TEST(VtkSceneController, MultipleDatasetsAddMultipleCurtains) { 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 编排 ── // 每次重建都把当前坐标轴设置下发给视图(clear 后须重设)。 @@ -330,3 +403,100 @@ TEST(VtkSceneController, ZoomAndFitForwarded) { c.fit(); 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); +} diff --git a/tests/data/test_3d_repo.cpp b/tests/data/test_3d_repo.cpp index 78127be..5d8b380 100644 --- a/tests/data/test_3d_repo.cpp +++ b/tests/data/test_3d_repo.cpp @@ -37,9 +37,31 @@ TEST(LocalSample3dRepo, DimensionOfMapsDdCode) { 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_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); } +// 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。 TEST(LocalSample3dRepo, LoadVolumeCallsBackWithValidGrid) { LocalSampleRepository base(kDir); @@ -113,6 +135,18 @@ TEST(Api3dRepo, VolumeInfoBeforeLoad) { EXPECT_EQ(info.params.colorScaleId, "src-a"); } +// loadMapLine(Api):loadAsync 返回空句柄 → onErr(不崩,给明确错误)。 +TEST(Api3dRepo, LoadMapLineNullHandleCallsOnError) { + StubAsyncRepo dsRepo; + auto frame = std::make_shared(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,不弹空对话框。 TEST(Api3dRepo, VolumeInfoUnknownIdReturnsFalse) { StubAsyncRepo dsRepo;